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
4 changes: 3 additions & 1 deletion .claude/settings.local.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,9 @@
"mcp__plugin_playwright_playwright__browser_navigate",
"mcp__plugin_playwright_playwright__browser_take_screenshot",
"mcp__plugin_playwright_playwright__browser_evaluate",
"Skill(playwright-cli)"
"Skill(playwright-cli)",
"mcp__plugin_context7_context7__query-docs",
"mcp__plugin_context7_context7__resolve-library-id"
]
}
}
57 changes: 44 additions & 13 deletions framework/hosting/simple_module_hosting/app_builder.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,14 +7,17 @@
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import APIRouter, FastAPI, Request
from fastapi.exceptions import HTTPException
from fastapi import APIRouter, FastAPI
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException
from inertia import (
Inertia,
InertiaConfig,
InertiaVersionConflictException,
inertia_version_conflict_exception_handler,
)
from simple_module_core.exceptions import NotFoundError
from simple_module_core.diagnostics import (
Diagnostic,
DiagnosticLevel,
Expand All@@ -30,7 +33,8 @@
from simple_module_db.listeners import register_listeners
from simple_module_db.session import init_db
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import RedirectResponse
from starlette.requests import Request
from starlette.responses import Response

from simple_module_hosting.health import router as health_router
from simple_module_hosting.middleware import (
Expand DownExpand Up@@ -203,19 +207,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
InertiaVersionConflictException,
inertia_version_conflict_exception_handler, # ty: ignore[invalid-argument-type]
)
app.add_exception_handler(HTTPException, _http_exception_handler) # ty: ignore[invalid-argument-type]
app.add_exception_handler(NotFoundError, _not_found_error_handler) # ty: ignore[invalid-argument-type]
app.add_exception_handler(Exception, _unhandled_exception_handler) # ty: ignore[invalid-argument-type]
for mod in modules:
mod.register_exception_handlers(app)

async def _handle_http_exception(request: Request, exc: HTTPException) -> RedirectResponse: # type: ignore[type-arg]
"""For Inertia requests, redirect on error instead of returning plain JSON."""
if "X-Inertia" in request.headers:
return RedirectResponse("/", status_code=303)
from fastapi.responses import JSONResponse

return JSONResponse({"detail": exc.detail}, status_code=exc.status_code) # type: ignore[return-value]

app.add_exception_handler(HTTPException, _handle_http_exception) # ty: ignore[invalid-argument-type]

# ── Phase 8: Middleware pipeline ───────────────────────
# Order matters: last added = first executed
# Execution: CorrelationId → RequestLogging → Security → Session → [module] → Inertia
Expand DownExpand Up@@ -254,6 +251,40 @@ async def _handle_http_exception(request: Request, exc: HTTPException) -> Redire
return app


_INERTIA_ERROR_STATUSES = frozenset({403, 404, 500})


async def _render_error_page(request: Request, status_code: int, message: str) -> Response:
config: InertiaConfig = request.app.state.inertia_config
try:
inertia = Inertia(request, config)
response = await inertia.render("Error", {"status": status_code, "message": message})
response.status_code = status_code
return response
except InertiaVersionConflictException as exc:
return await inertia_version_conflict_exception_handler(request, exc)
except Exception:
# Fallback if Inertia rendering itself fails (e.g. missing session)
logger.exception("Error page rendering failed, falling back to JSON")
return JSONResponse(status_code=status_code, content={"detail": message or "Internal Server Error"})


async def _http_exception_handler(request: Request, exc: HTTPException) -> Response:
if exc.status_code in _INERTIA_ERROR_STATUSES:
detail = str(exc.detail) if exc.detail else ""
return await _render_error_page(request, exc.status_code, detail)
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})


async def _not_found_error_handler(request: Request, exc: NotFoundError) -> Response:
return await _render_error_page(request, 404, str(exc))


async def _unhandled_exception_handler(request: Request, exc: Exception) -> Response:
logger.exception("Unhandled exception: %s", exc)
return await _render_error_page(request, 500, "")


def _setup_inertia(app: FastAPI, settings: Settings) -> None:
"""Configure fastapi-inertia with the Jinja2 template."""
from fastapi.templating import Jinja2Templates
Expand Down
21 changes: 19 additions & 2 deletions host/client_app/app.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import { createInertiaApp } from '@inertiajs/react';
import { createInertiaApp, router } from '@inertiajs/react';
import { ErrorBoundary } from '@ui/components/ErrorBoundary';
import { useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { resolvePage } from './pages';

Expand All@@ -8,7 +10,22 @@ createInertiaApp({
return page;
},
setup({ el, App, props }) {
createRoot(el).render(<App {...props} />);
function Root() {
const boundaryRef = useRef<ErrorBoundary>(null);

useEffect(() => {
// Reset the boundary on navigation so the next page can render.
return router.on('navigate', () => boundaryRef.current?.reset());
}, []);

return (
<ErrorBoundary ref={boundaryRef}>
<App {...props} />
</ErrorBoundary>
);
}

createRoot(el).render(<Root />);
},
progress: {
color: '#4B5563',
Expand Down
16 changes: 14 additions & 2 deletions host/client_app/pages.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,18 +4,22 @@
* Convention: modules/{name}/sm_{name}/pages/{PageName}.tsx
* Inertia component name: "{ModuleName}/{PageName}"
*
* Host-level pages live in host/client_app/pages/{PageName}.tsx
* and are registered as just "{PageName}" (e.g., "Error").
*
* Vite code-splits each page into its own chunk automatically.
* HMR works instantly — just edit any .tsx file.
*/

type PageModule = { default: React.ComponentType<Record<string, unknown>> };
type PageLoader = () => Promise<PageModule>;

const pageModules = import.meta.glob<PageModule>('../../modules/*/*/pages/*.tsx');
const modulePages = import.meta.glob<PageModule>('../../modules/*/*/pages/*.tsx');
const hostPages = import.meta.glob<PageModule>('./pages/*.tsx');

const pages: Record<string, PageLoader> = {};

for (const [filePath, loader] of Object.entries(pageModules)) {
for (const [filePath, loader] of Object.entries(modulePages)) {
// Extract module name and page name from file path
// e.g., "../../modules/products/sm_products/pages/Browse.tsx"
// -> moduleName = "Products", pageName = "Browse"
Expand All@@ -27,6 +31,14 @@ for (const [filePath, loader] of Object.entries(pageModules)) {
}
}

for (const [filePath, loader] of Object.entries(hostPages)) {
// e.g., "./pages/Error.tsx" -> "Error"
const match = filePath.match(/\.\/pages\/(\w+)\.tsx$/);
if (match) {
pages[match[1]] = loader;
}
}

export async function resolvePage(
name: string,
): Promise<React.ComponentType<Record<string, unknown>>> {
Expand Down
38 changes: 38 additions & 0 deletions host/client_app/pages/Error.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { Link } from '@inertiajs/react';
import { ErrorScreen } from '@ui/components/ErrorScreen';
import { Button } from '@ui/components/ui/button';

interface Props {
status: number;
message: string;
}

const titles: Record<number, string> = {
403: 'Forbidden',
404: 'Page Not Found',
500: 'Server Error',
};

const descriptions: Record<number, string> = {
403: "You don't have permission to access this page.",
404: "The page you're looking for doesn't exist or has been moved.",
500: 'Something went wrong on our end. Please try again later.',
};

function ErrorPage({ status, message }: Props) {
const title = titles[status] || 'Error';
const description = message || descriptions[status] || 'An unexpected error occurred.';

return (
<ErrorScreen hero={status} title={title} description={description}>
<Button asChild>
<Link href="/">Go Home</Link>
</Button>
<Button variant="outline" onClick={() => window.history.back()}>
Go Back
</Button>
</ErrorScreen>
);
}

export default ErrorPage;
Loading