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
31 changes: 26 additions & 5 deletions framework/core/simple_module_core/diagnostics/_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ def _check_empty_modules(self, modules: list[ModuleBase]) -> list[Diagnostic]:
"register_health_checks",
"register_exception_handlers",
"register_settings",
"template_dirs",
"static_mounts",
"locale_dirs",
"on_startup",
"on_shutdown",
)
if name in cls.__dict__
]
Expand Down Expand Up @@ -195,6 +200,25 @@ def _find_package_dir(self, package_name: str) -> Path | None:
return Path(locations[0])
return None

def _collect_tsx_pages(self, pages_dir: Path) -> set[str]:
"""Collect .tsx page identifiers relative to pages_dir, without extension.

Nested files are represented with forward slashes so the set compares
directly against inertia.render("Module/Sub/Page") keys. Subdirectories
whose names start with a lowercase letter (e.g. ``components/``,
``hooks/``) are treated as helper folders — not Inertia page roots —
and skipped, matching the PascalCase convention Inertia uses.
"""
if not pages_dir.exists():
return set()
pages: set[str] = set()
for f in pages_dir.rglob("*.tsx"):
rel = f.relative_to(pages_dir)
if any(part[:1].islower() for part in rel.parts[:-1]):
continue
pages.add(rel.with_suffix("").as_posix())
return pages

def _check_orphan_pages(
self,
mod: ModuleBase,
Expand All @@ -203,10 +227,7 @@ def _check_orphan_pages(
) -> list[Diagnostic]:
"""Find .tsx pages that aren't referenced by any inertia.render() call."""
pages_dir = src_dir / "pages"
if not pages_dir.exists():
return []

tsx_pages = {f.stem for f in pages_dir.glob("*.tsx")}
tsx_pages = self._collect_tsx_pages(pages_dir)
orphans = tsx_pages - rendered_pages

return [
Expand All @@ -229,7 +250,7 @@ def _check_phantom_renders(
) -> list[Diagnostic]:
"""Find inertia.render() calls that reference non-existent pages."""
pages_dir = src_dir / "pages"
tsx_pages = {f.stem for f in pages_dir.glob("*.tsx")} if pages_dir.exists() else set()
tsx_pages = self._collect_tsx_pages(pages_dir)
phantoms = rendered_pages - tsx_pages

return [
Expand Down
12 changes: 11 additions & 1 deletion framework/hosting/simple_module_hosting/_phase_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,17 @@ def install_middleware(
# scope["session"] is populated by the time we validate the token.
app.add_middleware(CSRFMiddleware)
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
app.add_middleware(SecurityHeadersMiddleware)
# In dev, relax CSP so the browser can fetch @vite/client, main.tsx, and
# the HMR WebSocket from the Vite origin. HSTS is also suppressed because
# dev runs over plain HTTP on loopback.
if settings.is_development:
app.add_middleware(
SecurityHeadersMiddleware,
content_security_policy=SecurityHeadersMiddleware.dev_csp(settings.vite_dev_url),
strict_transport_security=None,
)
else:
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(CorrelationIdMiddleware)

Expand Down
6 changes: 4 additions & 2 deletions framework/hosting/simple_module_hosting/app_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.settings_cookie_name = settings.i18n_cookie_name

# ── Phase 4: Module settings ───────────────────────────
state_before = set(vars(app.state))
# Starlette's State stores attributes in `_state`, so we snapshot that
# dict's keys (vars(app.state) only exposes `{'_state'}` itself).
state_before = set(app.state._state)
for mod in modules:
mod.register_settings(app)

# SM012: warn if register_settings was overridden but added nothing
if settings.is_development:
state_after = set(vars(app.state))
state_after = set(app.state._state)
check_settings_registration(modules, state_after - state_before)

# ── Phase 5: Module registrations ──────────────────────
Expand Down
25 changes: 25 additions & 0 deletions framework/hosting/simple_module_hosting/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class SecurityHeadersMiddleware:
# Production builds compile to hashed bundles, so this can be
# tightened with a nonce once Vite's preamble is removed in prod.
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"script-src-elem 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"img-src 'self' data: blob:; "
"font-src 'self' https://fonts.gstatic.com data:; "
Expand All @@ -68,6 +69,30 @@ class SecurityHeadersMiddleware:
)
_DEFAULT_HSTS = "max-age=31536000; includeSubDomains"

@staticmethod
def dev_csp(vite_dev_url: str) -> str:
"""Build a dev CSP that whitelists the Vite dev server.

In development the browser fetches ``@vite/client``, ``main.tsx``, and
React Refresh from the Vite origin (default ``http://localhost:5050``),
and opens a WebSocket there for HMR. Those fail under the prod CSP,
so we widen ``script-src*``/``connect-src``/``style-src`` for that
origin only (including the ``ws://`` equivalent for HMR).
"""
ws_url = vite_dev_url.replace("http://", "ws://").replace("https://", "wss://")
return (
"default-src 'self'; "
f"script-src 'self' 'unsafe-inline' 'unsafe-eval' {vite_dev_url}; "
f"script-src-elem 'self' 'unsafe-inline' {vite_dev_url}; "
f"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com {vite_dev_url}; "
"img-src 'self' data: blob:; "
"font-src 'self' https://fonts.gstatic.com data:; "
f"connect-src 'self' {vite_dev_url} {ws_url}; "
"frame-ancestors 'self'; "
"base-uri 'self'; "
"form-action 'self'"
)

def __init__(
self,
app: ASGIApp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react-swc": "^4.0.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import react from '@vitejs/plugin-react-swc';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

const projectRoot = path.resolve(__dirname, '..');
Expand Down
4 changes: 2 additions & 2 deletions framework/hosting/tests/test_inertia_i18n_shared_props.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ def test_inertia_shared_props_include_i18n_block_for_default_locale() -> None:


def test_inertia_shared_props_reflect_cookie_locale() -> None:
client = TestClient(_build_app())
resp = client.get("/shared", cookies={"locale": "es"})
client = TestClient(_build_app(), cookies={"locale": "es"})
resp = client.get("/shared")
body = resp.json()
assert body["i18n"]["locale"] == "es"
assert body["i18n"]["messages"] == {"hello": "Hola"}
Expand Down
20 changes: 8 additions & 12 deletions framework/hosting/tests/test_locale_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ async def endpoint(request: Request) -> JSONResponse:

def test_uses_cookie_when_present_and_supported() -> None:
app = _build_app(["en", "es"], "en")
client = TestClient(app)
resp = client.get("/", cookies={"locale": "es"})
client = TestClient(app, cookies={"locale": "es"})
resp = client.get("/")
assert resp.json() == {"locale": "es"}


def test_ignores_cookie_when_locale_not_supported() -> None:
app = _build_app(["en", "es"], "en")
client = TestClient(app)
resp = client.get("/", cookies={"locale": "de"})
client = TestClient(app, cookies={"locale": "de"})
resp = client.get("/")
# Falls through to Accept-Language, then to default (en).
assert resp.json() == {"locale": "en"}

Expand Down Expand Up @@ -63,19 +63,15 @@ def test_falls_back_to_default_when_nothing_matches() -> None:

def test_cookie_takes_precedence_over_accept_language() -> None:
app = _build_app(["en", "es"], "en")
client = TestClient(app)
resp = client.get(
"/",
cookies={"locale": "es"},
headers={"Accept-Language": "de"},
)
client = TestClient(app, cookies={"locale": "es"})
resp = client.get("/", headers={"Accept-Language": "de"})
assert resp.json() == {"locale": "es"}


def test_custom_cookie_name() -> None:
app = _build_app(["en", "es"], "en", cookie_name="lang")
client = TestClient(app)
resp = client.get("/", cookies={"lang": "es"})
client = TestClient(app, cookies={"lang": "es"})
resp = client.get("/")
assert resp.json() == {"locale": "es"}


Expand Down
4 changes: 2 additions & 2 deletions framework/hosting/tests/test_translator_dep.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ def hi(t: TranslatorDep, name: str = "friend") -> dict[str, str]:


def test_translator_dep_uses_request_locale() -> None:
client = TestClient(_build_app())
resp = client.get("/hi?name=Ana", cookies={"locale": "es"})
client = TestClient(_build_app(), cookies={"locale": "es"})
resp = client.get("/hi?name=Ana")
assert resp.json() == {"greeting": "Hola, Ana", "locale": "es"}


Expand Down
5 changes: 2 additions & 3 deletions host/client_app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,12 @@
"@types/node": "^25.6.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react-swc": "^4.3.0",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"rollup-plugin-visualizer": "~5.12.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^8.0.8",
"vite-tsconfig-paths": "^6.1.1"
"vite": "^8.0.8"
}
}
32 changes: 9 additions & 23 deletions host/client_app/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import fs from 'node:fs';
import path from 'node:path';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react-swc';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

const projectRoot = path.resolve(__dirname, '../..');

Expand All @@ -18,39 +17,18 @@ const analyzeBundle = process.env.ANALYZE === '1';
// dev server can read files outside the workspace root.
const manifestPath = path.resolve(__dirname, 'modules.manifest.json');
const moduleFsAllow: string[] = [];
const moduleTsconfigs: string[] = [];
let manifest: Record<string, string> = {};
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
// Manifest absent (sm gen-pages hasn't run yet) — proceed with empty set.
}
for (const pagesDir of Object.values(manifest)) {
// Pages live at <moduleRoot>/<pkg>/pages/*.tsx — climb two levels to the
// module root where its tsconfig.json and package.json live.
const moduleRoot = path.dirname(path.dirname(pagesDir));
moduleFsAllow.push(path.dirname(pagesDir));
try {
fs.statSync(path.join(moduleRoot, 'tsconfig.json'));
moduleTsconfigs.push(path.join(moduleRoot, 'tsconfig.json'));
} catch {
// Module has no tsconfig (e.g. backend-only) — skip.
}
}

export default defineConfig({
// `tsconfigPaths` makes Vite honor each package's tsconfig `paths` at
// import-resolution time. Each package's `@/*` maps to its own local root,
// so there's no global `@` alias here — the plugin picks the right tsconfig
// per importing file.
plugins: [
tsconfigPaths({
projects: [
path.resolve(__dirname, 'tsconfig.json'),
path.resolve(projectRoot, 'packages/ui/tsconfig.json'),
...moduleTsconfigs,
],
}),
react(),
tailwindcss(),
...(analyzeBundle
Expand All @@ -64,6 +42,14 @@ export default defineConfig({
]
: []),
],
// Vite 8 resolves tsconfig `paths` natively from this app's tsconfig.json.
// The only live alias in the repo is `@simple-module/ui/*`, which the host
// tsconfig maps to `../../packages/ui/src/*` — that resolves identically
// regardless of which file does the importing, so we no longer need to
// feed Vite the per-module tsconfigs.
resolve: {
tsconfigPaths: true,
},
root: __dirname,
build: {
outDir: '../static/dist',
Expand Down
Loading