From fcaedda536239d93d15ed5ed611e5cbdcecc2710 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 23 Apr 2026 22:41:59 +0200 Subject: [PATCH 1/2] docs: correct introduction and quick-start against real framework Introduction: - Replace the Program.cs snippet's generated-method calls with the real public API (AddSimpleModule/UseSimpleModule) and clarify that the source-generated helpers are invoked by them. - Tech stack: Blazor SSR -> Inertia.js middleware (static HTML shell with embedded JSON props); no Blazor in the rendering path. - Fix Pages/index.ts example to use ./Browse (components live in Pages/) and unknown over any. Quick start: - Module tree rooted at src/modules/ to match CLI's SolutionContext, replacing fabricated Views/vite.config/package.json entries with what sm new module actually creates (Constants/DbContext/Service files, Endpoints/{Module}/GetAllEndpoint.cs, Events/, tsconfig, test Unit/ + Integration/ folders). - Add note that frontend scaffolding (Pages/, Views/) is created by the first sm new feature, not by sm new module. - Replace unsupported 'sm new feature Products/Browse' slash syntax with 'sm new feature Browse --module Products' (+ interactive fallback). - Endpoint filename/class: BrowseProducts -> BrowseEndpoint to match the CLI template ({Feature}Endpoint.cs). - Pages/index.ts import uses the @/Views/ alias that the CLI emits. --- docs/site/getting-started/introduction.md | 24 +++++----- docs/site/getting-started/quick-start.md | 54 ++++++++++++++--------- 2 files changed, 44 insertions(+), 34 deletions(-) diff --git a/docs/site/getting-started/introduction.md b/docs/site/getting-started/introduction.md index ed112280..7ca89bf7 100644 --- a/docs/site/getting-started/introduction.md +++ b/docs/site/getting-started/introduction.md @@ -41,16 +41,16 @@ SimpleModule includes a Roslyn incremental source generator that scans your asse - Endpoint classes implementing `IEndpoint` or `IViewEndpoint` - Data transfer objects marked with `[Dto]` -The generator emits extension methods -- `AddModules()`, `MapModuleEndpoints()`, `CollectModuleMenuItems()` -- that your host app calls in `Program.cs`. There is no reflection at runtime. The generated code is plain C# that you can inspect in your IDE. +The generator emits extension methods -- `AddModules()`, `MapModuleEndpoints()`, `CollectModuleMenuItems()` -- which the framework's `AddSimpleModule()` and `UseSimpleModule()` helpers call for you. There is no reflection at runtime. The generated code is plain C# that you can inspect in your IDE. ```csharp -// Program.cs — calls generated extension methods +// Program.cs — two calls wire everything up var builder = WebApplication.CreateBuilder(args); -builder.AddModules(); // registers all module services +builder.AddSimpleModule(); // registers framework services + all modules var app = builder.Build(); -app.MapModuleEndpoints(); // maps all discovered endpoints -app.CollectModuleMenuItems(); // builds the navigation menu +await app.UseSimpleModule(); // configures middleware, maps endpoints, Inertia.js +await app.RunAsync(); ``` ### React + Inertia.js Frontend @@ -64,11 +64,11 @@ This means: - **Full React ecosystem** -- use any React library. The framework doesn't limit what you can do on the client. ```typescript -// modules/Products/src/Products/Pages/index.ts -export const pages: Record = { - 'Products/Browse': () => import('../Views/Browse'), - 'Products/Manage': () => import('../Views/Manage'), - 'Products/Create': () => import('../Views/Create'), +// modules/Products/src/SimpleModule.Products/Pages/index.ts +export const pages: Record = { + 'Products/Browse': () => import('./Browse'), + 'Products/Manage': () => import('./Manage'), + 'Products/Create': () => import('./Create'), }; ``` @@ -179,7 +179,7 @@ The Roslyn source generator runs during compilation. It discovers `ProductsModul **4. Everything is registered** -The generated `AddModules()` method calls `ProductsModule.ConfigureServices()`. The generated `MapModuleEndpoints()` method maps `BrowseProducts` under the `/products` route prefix. The generated `CollectModuleMenuItems()` method gathers any menu items the module registered. All at compile time. All type-safe. +When you call `AddSimpleModule()`, the generated `AddModules()` runs and invokes `ProductsModule.ConfigureServices()`. When you call `UseSimpleModule()`, the generated `MapModuleEndpoints()` maps `BrowseProducts` under the `/products` route prefix, and `CollectModuleMenuItems()` gathers any menu items the module registered. All at compile time. All type-safe. ::: tip Zero Configuration You don't write registration code, startup configuration, or reflection-based discovery logic. Add a class, implement an interface, build. The generator handles the rest. @@ -191,7 +191,7 @@ You don't write registration code, startup configuration, or reflection-based di |-------|-----------| | Runtime | .NET 10 | | Frontend | React 19, Inertia.js | -| Server rendering | Blazor SSR | +| Server rendering | Inertia.js (static HTML shell with embedded JSON props) | | Build tooling | Vite, Tailwind CSS 4 | | Source generation | Roslyn incremental generators | | Component library | Radix UI | diff --git a/docs/site/getting-started/quick-start.md b/docs/site/getting-started/quick-start.md index 2f18db1f..c27c4f26 100644 --- a/docs/site/getting-started/quick-start.md +++ b/docs/site/getting-started/quick-start.md @@ -86,32 +86,40 @@ sm new module Products This creates three projects following the standard module pattern: ``` -modules/Products/ +src/modules/Products/ ├── src/ -│ ├── Products/ # Module implementation +│ ├── Products/ # Module implementation │ │ ├── Products.csproj -│ │ ├── ProductsModule.cs # [Module] class with ConfigureServices -│ │ ├── Endpoints/ # API and view endpoints -│ │ ├── Pages/ -│ │ │ └── index.ts # React page registry -│ │ ├── Views/ # React page components -│ │ ├── vite.config.ts # Vite library mode build -│ │ └── package.json -│ └── Products.Contracts/ # Public interface for other modules +│ │ ├── ProductsModule.cs # [Module] class with ConfigureServices +│ │ ├── ProductsConstants.cs # Module constants +│ │ ├── ProductsDbContext.cs # EF Core DbContext +│ │ ├── ProductService.cs # Default IProductContracts implementation +│ │ ├── Endpoints/ +│ │ │ └── Products/ +│ │ │ └── GetAllEndpoint.cs # Starter endpoint +│ │ └── tsconfig.json +│ └── Products.Contracts/ # Public interface for other modules │ ├── Products.Contracts.csproj -│ ├── IProductContracts.cs # Contract interface -│ └── ProductDto.cs # Shared DTOs with [Dto] attribute +│ ├── IProductContracts.cs # Contract interface +│ ├── Product.cs # Shared DTO with [Dto] attribute +│ └── Events/ +│ └── ProductCreatedEvent.cs # Contract-level event └── tests/ - └── Products.Tests/ # xUnit test project - └── Products.Tests.csproj + └── Products.Tests/ # xUnit test project + ├── Products.Tests.csproj + ├── GlobalUsings.cs + ├── Unit/ProductServiceTests.cs + └── Integration/ProductsEndpointTests.cs ``` The CLI also: - Adds `ProjectReference` entries to the host app - Registers all projects in `SimpleModule.slnx` -- Sets up the Vite build configuration -- Creates a starter endpoint and React page + +::: info Frontend files are added on first feature +`sm new module` creates only the C# backend and test projects. `Pages/index.ts`, `Views/`, and the frontend wiring are created the first time you run `sm new feature` against the module. +::: ### The Generated Module Class @@ -150,19 +158,21 @@ Marking a type with `[Dto]` tells the source generator to include it in JSON ser Add a browsing feature to the Products module: ```bash -sm new feature Products/Browse +sm new feature Browse --module Products ``` +Run `sm new feature` with no arguments for an interactive prompt that asks for the feature name, module, HTTP method, and route. + This scaffolds: -- A C# endpoint class (`Endpoints/Products/BrowseProducts.cs`) +- A C# endpoint class (`Endpoints/Products/BrowseEndpoint.cs`) - A React page component (`Views/Browse.tsx`) - An entry in the page registry (`Pages/index.ts`) ### The Endpoint ```csharp -public sealed class BrowseProducts : IViewEndpoint +public sealed class BrowseEndpoint : IViewEndpoint { public static void Map(IEndpointRouteBuilder app) => app.MapGet("/", Handler); @@ -210,9 +220,9 @@ export default function Browse({ products }: Props) { ### The Page Registry ```typescript -// modules/Products/src/Products/Pages/index.ts -export const pages: Record = { - "Products/Browse": () => import("../Views/Browse"), +// src/modules/Products/src/Products/Pages/index.ts +export const pages: Record = { + "Products/Browse": () => import("@/Views/Browse"), }; ``` From 2dca508ae917bdea6b27abb1dcb4e0d1e45e0bc6 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 23 Apr 2026 22:54:10 +0200 Subject: [PATCH 2/2] docs: validate and correct all site pages against real framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers all 33 pages under docs/site/ that contained factual drift from the framework, CLI, modules, packages, and test projects. Findings were validated against source files (framework/, modules/, cli/, packages/, tests/, scripts/, Dockerfile*, docker-compose.yml, template/) before editing. Getting started / index - index.md: drop "Blazor SSR" — the shell is served by ASP.NET Core + Inertia.js middleware. - project-structure.md: split CLI-scaffolded (src/modules/) vs framework-repo (modules/) layouts; module/contracts dirs use the SimpleModule.{Name} prefix; remove fictitious Views/, Data/Entities, Services/, Dtos/ folders; reference the real Product/Request types; csproj references SimpleModule.Hosting; Program.cs uses AddSimpleModule/UseSimpleModule; expand npm workspaces list. CLI (new-project, new-module, new-feature, doctor) - new-project.md: add --dry-run and --framework-version options; replace fictitious Api/Core/Database/Generator projects with the real Host project; include the starter src/modules/Items module and the scaffolded root files (package.json, biome.json, tsconfig.json, .editorconfig, nuget.config, ClientApp/, Styles/, wwwroot/, launchSettings); correct the run command. - new-module.md: all paths use src/modules/; add --dry-run; add tsconfig.json to the implementation tree; run command uses .Host. - new-feature.md: add --no-view and --dry-run; document Views/{Feature}.tsx + Pages/index.ts registry entry via @/Views/ alias; note that the CLI auto-adds the entry. - doctor.md: replace "five categories" with the 12 real checks; --fix also handles Pages/index.ts entries and npm workspace globs. Guide A (modules, endpoints, contracts, events, inertia) - modules.md: SimpleModule.{Name} project dirs; endpoints live in Pages/ alongside components; csproj uses Microsoft.NET.Sdk + FrameworkReference. - endpoints.md: drop Endpoints/+Views/ split; IViewEndpoint classes sit next to .tsx in Pages/. - contracts.md: extract-ts-types writes types.ts into each module's source dir. - inertia.md: remove the fabricated Blazor SSR pipeline; document the real HtmlFileInertiaPageRenderer (static wwwroot/index.html with placeholder substitution); fix fallback version to build timestamp; fix Pages/index.ts imports to ./Name. Guide B (database, permissions, menus, settings, localization) - database.md: fix the ApplyModuleSchema snippet to resolve connectionString from dbOptions and call ApplyEntityConventions. - permissions.md: replace the fictitious Admin/HasClaim handler with the real HasPermission delegation; document wildcard matching. - menus.md: add Roles and RequiredPermission to the MenuItem table; drop the "MenuRegistry sorts by Order" claim; replace "Blazor layout components" with React. - settings.md: clarify DefaultValue is a raw string; fix double-encoded example defaults. - localization.md: split fallback behavior between React (returns un-prefixed key) and .NET (returns null); note user-setting lookup reads ClaimTypes.NameIdentifier only. Guide C (ai-agents, background-jobs, file-storage, error-pages) - ai-agents.md: Ollama key BaseUrl→Endpoint and llama3→llama3.2; note Anthropic.Model is not wired; rewrite AddSimpleModuleAgents description to reflect what it actually registers; Temperature is float?; replace Agent SettingDefinitions with AgentOptions. - background-jobs.md: drop TickerQ attribution — the module uses Cronos + in-house DatabaseJobQueue/JobProcessorService/ StalledJobSweeperService; fix API routes and :guid constraints; add WorkerMode and a split-deployment subsection; GetRecurringCountAsync with CT defaults. - file-storage.md: add CancellationToken = default to IStorageProvider methods; S3.ServiceUrl example as null (Uri?); browse URL /files/; IFileStorageContracts gets userId parameters and StoredFile overloads. - error-pages.md: no changes needed — verified against SimpleModuleHostExtensions. Frontend (overview, pages, styling, vite) - overview.md: drop "Blazor SSR"; SimpleModule.Products/ project dir; ./Browse imports; rewrite resolvePage snippet to match the real resolve-page.ts (throws on missing page). - pages.md: SimpleModule. dir prefix; ./ imports; endpoint lives in Pages/; replace "silently 404" with the real explicit-throw + toast surface. - styling.md: React TSX not Blazor Razor; swap primary/accent palette to emerald #059669 / teal #0f766e with matching rgba shadows. - vite.md: Vite 8 + Rolldown; import.meta.dirname; SimpleModule. project dir and bundle names; full externals list (5 entries); rolldownOptions; clarify dev:build is repo-root only. Testing (overview, unit, integration, e2e) - overview.md: NSubstitute is only used in select modules. - unit-tests.md: ProductService takes IMessageBus; OrderFaker sets UserId as a plain string; replace the fictitious OrderCreatedAuditHandler/IAuditContext example with an IMessageBus substitution pattern. - integration-tests.md: fix the invalid named-arg+params C# call — pass permissions positionally. - e2e-tests.md: bump Playwright; reflect real playwright.config.ts (retries 0, chromium only, CI launch-profile http, webServer timeout 120s, top-level timeouts); fix auth.setup.ts to navigate directly to /Identity/Account/Login; correct page-object tree. Advanced / reference (deployment, interceptors, source-generator, type-generation, api, configuration, acknowledgments) - deployment.md: rewrite Dockerfile to match the real 5-stage build (Node 22, non-root appuser, DEPLOY_VERSION ARG); expand docker-compose to api+worker+postgres:17 with healthcheck, shared storage_data volume, and POSTGRES_PASSWORD/APP_BASE_URL template vars; add OpenIddict__BaseUrl and BackgroundJobs__WorkerMode to the env table; drop the false EnsureCreated claim. - interceptors.md: document the three shipped interceptors (EntityInterceptor, DomainEventInterceptor, EntityChangeInterceptor) and soften the "never inject non-optional services" rule to match the framework's own usage; rename example to IHasCreationTime/ IHasModificationTime. - source-generator.md: HostingExtensionsEmitter emits both AddSimpleModule and UseSimpleModule; drop the duplicate ViewPagesEmitter entry; pass cancellation token in the Select snippet. - type-generation.md: output path includes the SimpleModule. project dir prefix. - api.md: add Roles and RequiredPermission to MenuItem. - configuration.md: replace default appsettings.json with the real shape (Storage, AI.Ollama, Agents, Rag.StructuredRag, Localization, Passkeys); Ollama keys corrected; add OpenIddict, Passkeys, and BackgroundJobs sections; docker-compose env vars include OpenIddict__BaseUrl and BackgroundJobs__WorkerMode. - acknowledgments.md: add Wolverine, Cronos, and Anthropic.SDK. --- docs/site/advanced/deployment.md | 115 ++++++++-- docs/site/advanced/interceptors.md | 24 ++- docs/site/advanced/source-generator.md | 6 +- docs/site/advanced/type-generation.md | 6 +- docs/site/cli/doctor.md | 21 +- docs/site/cli/new-feature.md | 24 ++- docs/site/cli/new-module.md | 10 +- docs/site/cli/new-project.md | 52 +++-- docs/site/frontend/overview.md | 35 ++- docs/site/frontend/pages.md | 41 ++-- docs/site/frontend/styling.md | 16 +- docs/site/frontend/vite.md | 34 +-- .../site/getting-started/project-structure.md | 202 ++++++++++++------ docs/site/guide/ai-agents.md | 66 ++++-- docs/site/guide/background-jobs.md | 39 +++- docs/site/guide/contracts.md | 4 +- docs/site/guide/database.md | 5 + docs/site/guide/endpoints.md | 13 +- docs/site/guide/file-storage.md | 34 ++- docs/site/guide/inertia.md | 102 ++++----- docs/site/guide/localization.md | 8 +- docs/site/guide/menus.md | 10 +- docs/site/guide/modules.md | 42 ++-- docs/site/guide/permissions.md | 13 +- docs/site/guide/settings.md | 6 +- docs/site/index.md | 2 +- docs/site/reference/acknowledgments.md | 13 ++ docs/site/reference/api.md | 9 + docs/site/reference/configuration.md | 97 ++++++++- docs/site/testing/e2e-tests.md | 97 +++++---- docs/site/testing/integration-tests.md | 6 +- docs/site/testing/overview.md | 2 +- docs/site/testing/unit-tests.md | 22 +- 33 files changed, 783 insertions(+), 393 deletions(-) diff --git a/docs/site/advanced/deployment.md b/docs/site/advanced/deployment.md index bbc7b770..2c80789a 100644 --- a/docs/site/advanced/deployment.md +++ b/docs/site/advanced/deployment.md @@ -10,42 +10,80 @@ SimpleModule applications deploy as standard ASP.NET applications. This guide co ### Dockerfile -The project includes a multi-stage Dockerfile that produces a minimal runtime image: +The project includes a multi-stage Dockerfile that produces a minimal runtime image. The snippet below is a simplified reference — see the repository's actual `Dockerfile` for the full script, which lists every module's `.csproj` and workspace `package.json` explicitly for optimal layer caching: ```dockerfile -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +# Stage 1: Base — .NET SDK + Node.js 22 +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS base WORKDIR /src - -# Copy project files and restore (layer caching) -COPY Directory.Build.props Directory.Packages.props ./ +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Stage 2: .NET restore (cached unless .csproj / props files change) +FROM base AS restore +WORKDIR /src +COPY global.json Directory.Build.props Directory.Packages.props .editorconfig ./ COPY *.slnx ./ -COPY framework/SimpleModule.Core/*.csproj framework/SimpleModule.Core/ -COPY framework/SimpleModule.Database/*.csproj framework/SimpleModule.Database/ -COPY framework/SimpleModule.Generator/*.csproj framework/SimpleModule.Generator/ -COPY template/SimpleModule.Host/*.csproj template/SimpleModule.Host/ -# ... module project files ... +# Copy every framework/module/package .csproj individually for layer caching +# ... (see real Dockerfile for the full list) ... RUN dotnet restore template/SimpleModule.Host/SimpleModule.Host.csproj -# Build and publish +# Stage 3: npm restore (cached unless package.json / lockfile changes) +FROM restore AS npm-restore +WORKDIR /src +COPY package.json package-lock.json ./ +# Copy every workspace package.json individually for layer caching +# ... (see real Dockerfile for the full list) ... +RUN npm ci + +# Stage 4: Frontend build + .NET publish +FROM npm-restore AS build +WORKDIR /src COPY . . +RUN npm run build \ + && npx @tailwindcss/cli \ + -i template/SimpleModule.Host/Styles/app.css \ + -o template/SimpleModule.Host/wwwroot/css/app.css \ + --minify RUN dotnet publish template/SimpleModule.Host/SimpleModule.Host.csproj \ - -c Release -o /app/publish + -c Release -o /app/publish --no-restore \ + -p:ErrorOnDuplicatePublishOutputFiles=false \ + -p:JsBuildCommand=echo +# Stage 5: Runtime (slim image, non-root user) FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app -COPY --from=build /app/publish . +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && apt-get clean && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 1001 appgroup \ + && useradd --system --uid 1001 --gid appgroup --create-home appuser +COPY --from=build --chown=appuser:appgroup /app/publish . +RUN mkdir -p /app/data /app/storage && chown appuser:appgroup /app/data /app/storage + +# DEPLOY_VERSION feeds Inertia's asset-version header; if empty, the framework +# falls back to the entry assembly's last-write timestamp. +ARG DEPLOY_VERSION= +ENV DEPLOYMENT_VERSION=${DEPLOY_VERSION} + +USER appuser EXPOSE 8080 ENTRYPOINT ["dotnet", "SimpleModule.Host.dll"] ``` Key points: -- **Multi-stage build** -- the SDK image is only used for building; the final image uses the smaller `aspnet` runtime image -- **Layer caching** -- project files are copied and restored before the full source copy, so NuGet restore is cached across builds +- **Five stages** -- `base` (SDK + Node 22), `restore` (.NET restore), `npm-restore` (workspace npm ci), `build` (frontend + dotnet publish), `runtime` (slim aspnet image) +- **Non-root runtime** -- creates `appuser` (UID 1001) and runs the container as that user; owns `/app/data` (SQLite) and `/app/storage` (local files) +- **Per-project COPY for layer caching** -- every `.csproj` and workspace `package.json` is copied individually so `dotnet restore` and `npm ci` only re-run when those manifests change +- **`DEPLOY_VERSION` build arg** -- feeds `DEPLOYMENT_VERSION` for Inertia cache-busting; override with `--build-arg DEPLOY_VERSION=$(git rev-parse --short HEAD)` for deterministic versions - The runtime container exposes port **8080** ### Docker Compose -For local testing or simple deployments, use `docker-compose.yml`: +For local testing or simple deployments, use `docker-compose.yml`. The real compose file defines three services — `api`, `worker`, and `postgres` — so background jobs run in a dedicated consumer process while the web tier stays in producer mode: ```yaml services: @@ -54,17 +92,46 @@ services: ports: - "8080:8080" environment: - - ASPNETCORE_ENVIRONMENT=Production - - Database__DefaultConnection=Host=postgres;Database=simplemodule;Username=simplemodule;Password=simplemodule + ASPNETCORE_ENVIRONMENT: Development + Database__DefaultConnection: "Host=postgres;Port=5432;Database=simplemodule;Username=simplemodule;Password=${POSTGRES_PASSWORD:-simplemodule}" + Database__Provider: PostgreSQL + OpenIddict__BaseUrl: ${APP_BASE_URL:-http://localhost:8080} + # api enqueues jobs but never executes them — the worker does. + BackgroundJobs__WorkerMode: Producer + volumes: + - storage_data:/app/storage + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8080/health/live || exit 1"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + restart: unless-stopped + + worker: + build: + context: . + dockerfile: Dockerfile.worker + environment: + DOTNET_ENVIRONMENT: Development + Database__DefaultConnection: "Host=postgres;Port=5432;Database=simplemodule;Username=simplemodule;Password=${POSTGRES_PASSWORD:-simplemodule}" + Database__Provider: PostgreSQL + BackgroundJobs__WorkerMode: Consumer + volumes: + - storage_data:/app/storage depends_on: postgres: condition: service_healthy + restart: unless-stopped postgres: - image: postgres:16 + image: postgres:17 environment: POSTGRES_USER: simplemodule - POSTGRES_PASSWORD: simplemodule + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-simplemodule} POSTGRES_DB: simplemodule ports: - "5432:5432" @@ -75,9 +142,11 @@ services: interval: 10s timeout: 5s retries: 5 + restart: unless-stopped volumes: pgdata: + storage_data: ``` Start with: @@ -86,7 +155,7 @@ Start with: docker compose up -d ``` -The API service waits for PostgreSQL to pass its health check before starting. +The API service waits for PostgreSQL to pass its health check before starting. The `storage_data` volume is shared between `api` and `worker` so uploaded files are visible from both the web upload path and the background job path. ## CI/CD Pipeline @@ -190,6 +259,8 @@ For production, always use environment variables or a secrets manager for connec | `ASPNETCORE_ENVIRONMENT` | Runtime environment | `Production`, `Development` | | `Database__DefaultConnection` | Database connection string | `Host=...;Database=...` | | `Database__Provider` | Database provider | `Sqlite`, `PostgreSQL` | +| `OpenIddict__BaseUrl` | Public base URL used to register OpenIddict redirect URIs | `https://app.example.com` | +| `BackgroundJobs__WorkerMode` | Role in the background-job pipeline | `Producer` (web tier) or `Consumer` (worker tier) | ### Docker Environment @@ -205,7 +276,7 @@ docker run -d \ ## Database Initialization -SimpleModule uses `EnsureCreated()` by default, which creates the database schema if it does not exist. For production environments with evolving schemas, use EF Core migrations per module: +SimpleModule relies on EF Core migrations per module — there is no `EnsureCreated()` bootstrap in the framework. Generate and apply migrations for each module's DbContext: ```bash # Add a migration for a specific module's DbContext diff --git a/docs/site/advanced/interceptors.md b/docs/site/advanced/interceptors.md index 83d4fef6..83b4e0c8 100644 --- a/docs/site/advanced/interceptors.md +++ b/docs/site/advanced/interceptors.md @@ -6,6 +6,16 @@ outline: deep SimpleModule supports EF Core `SaveChangesInterceptor` for cross-cutting concerns like audit logging, soft deletes, and timestamp management. The source generator auto-discovers interceptors and wires them into the DbContext pipeline. +## Shipped Interceptors + +The framework ships three interceptors under `framework/SimpleModule.Database/Interceptors/`, all auto-registered: + +| Interceptor | Purpose | +|-------------|---------| +| `EntityInterceptor` | Populates `CreatedAt`/`UpdatedAt`, audit user fields, concurrency stamps, versioning, tenant IDs, and converts hard deletes into soft deletes based on the interfaces an entity implements (`IHasCreationTime`, `IHasModificationTime`, `IAuditable`, `IHasConcurrencyStamp`, `IVersioned`, `IMultiTenant`, `ISoftDelete`). | +| `DomainEventInterceptor` | Collects events from `IHasDomainEvents` entities before save and dispatches them via Wolverine's `IMessageBus` after a successful save. | +| `EntityChangeInterceptor` | Captures entity changes before save and dispatches them to typed `IEntityChangeHandler` implementations after save. | + ## Overview A `SaveChangesInterceptor` hooks into the EF Core save pipeline, allowing you to inspect or modify entities before or after they are persisted. Common use cases include: @@ -33,7 +43,7 @@ This happens because: ## The Solution: Lazy Resolution -Inject `IServiceProvider?` as an optional parameter and resolve dependencies at interception time -- not at construction time. +Inject `IServiceProvider` and resolve dependencies at interception time -- not at construction time. The framework's own interceptors do exactly this: `DomainEventInterceptor` and `EntityChangeInterceptor` take a non-optional `IServiceProvider` and resolve services (like Wolverine's `IMessageBus` or typed change handlers) inside `SavingChangesAsync`/`SavedChangesAsync`. `EntityInterceptor` takes `IHttpContextAccessor` and an optional `ITenantContext?` directly — both are safe because they don't depend on a DbContext. ### Correct Pattern @@ -83,9 +93,11 @@ public sealed class BadInterceptor( ### Constructor Parameters -- **Never** inject services that transitively depend on a DbContext into the interceptor constructor -- **Do** inject `IServiceProvider?` as an optional dependency when runtime service resolution is needed -- **Do** inject simple services (like `ILogger`, `TimeProvider`) that have no DbContext dependency +- **Never** inject services that transitively depend on a DbContext into the interceptor constructor — that's what causes the deadlock +- **Do** inject `IServiceProvider` (optional via `IServiceProvider? = null` if you want the interceptor to work without DI in unit tests) when runtime service resolution is needed +- **Do** inject simple services (like `ILogger`, `TimeProvider`, `IHttpContextAccessor`) that have no DbContext dependency — the framework's `EntityInterceptor` does this with `IHttpContextAccessor` and `ITenantContext?` + +The `SM0039` diagnostic flags interceptors whose constructor parameters transitively depend on a DbContext, so you'll see a compile-time warning before you hit the runtime deadlock. ### Service Resolution Timing @@ -114,12 +126,12 @@ public sealed class TimestampInterceptor( foreach (var entry in eventData.Context.ChangeTracker.Entries()) { - if (entry.State == EntityState.Added && entry.Entity is IHasCreatedAt created) + if (entry.State == EntityState.Added && entry.Entity is IHasCreationTime created) { created.CreatedAt = now; } - if (entry.State == EntityState.Modified && entry.Entity is IHasUpdatedAt updated) + if (entry.State == EntityState.Modified && entry.Entity is IHasModificationTime updated) { updated.UpdatedAt = now; } diff --git a/docs/site/advanced/source-generator.md b/docs/site/advanced/source-generator.md index 85604a4d..0076a875 100644 --- a/docs/site/advanced/source-generator.md +++ b/docs/site/advanced/source-generator.md @@ -135,7 +135,7 @@ The generator feeds `DiscoveryData` through a pipeline of **emitters**, each res | `LocalizationExtensionsEmitter` | `LocalizationExtensions.g.cs` | Aggregates localization resources across modules | | `RoutesEmitter` | `ModuleRoutes.g.cs` | Strongly-typed C# route constants | | `TypeScriptRoutesEmitter` | `TypeScriptRoutes.g.cs` | Embedded TypeScript route constants for the ClientApp | -| `HostingExtensionsEmitter` | `HostingExtensions.g.cs` | Top-level `AddSimpleModule()` that orchestrates all registrations | +| `HostingExtensionsEmitter` | `HostingExtensions.g.cs` | Top-level `AddSimpleModule()` (service registration) and `UseSimpleModule()` (middleware + endpoint mapping) that orchestrate all framework wiring | ### Frontend Integration @@ -158,7 +158,6 @@ The generator feeds `DiscoveryData` through a pipeline of **emitters**, each res | Emitter | Generated File | Purpose | |---------|---------------|---------| | `JsonResolverEmitter` | `ModulesJsonResolver.g.cs` | AOT-compatible JSON type info resolver for all DTO types | -| `ViewPagesEmitter` | `ViewPages.g.cs` | Maps view endpoints to React component names | | `DiagnosticEmitter` | _(diagnostics only)_ | Reports compiler warnings/errors for illegal module references and other issues | ## The Discovery-Emit Pipeline @@ -224,7 +223,8 @@ public class ModuleDiscovererGenerator : IIncrementalGenerator public void Initialize(IncrementalGeneratorInitializationContext context) { var dataProvider = context.CompilationProvider.Select( - static (compilation, _) => SymbolDiscovery.Extract(compilation) + static (compilation, cancellationToken) => + SymbolDiscovery.Extract(compilation, cancellationToken) ); context.RegisterSourceOutput( diff --git a/docs/site/advanced/type-generation.md b/docs/site/advanced/type-generation.md index 8bf102ae..6b361d02 100644 --- a/docs/site/advanced/type-generation.md +++ b/docs/site/advanced/type-generation.md @@ -26,7 +26,7 @@ extract-ts-types.mjs (build tool) │ Writes types.ts into each module's src/ directory │ ▼ -modules/{Module}/src/{Module}/types.ts +modules/{Module}/src/SimpleModule.{Module}/types.ts Ready for import in React components ``` @@ -127,13 +127,13 @@ You must build the .NET project before running type generation, since the tool r Each module gets its own `types.ts` file at: ``` -modules/{ModuleName}/src/{ModuleName}/types.ts +modules/{ModuleName}/src/SimpleModule.{ModuleName}/types.ts ``` For example, the Products module produces: ``` -modules/Products/src/Products/types.ts +modules/Products/src/SimpleModule.Products/types.ts ``` The file is marked as auto-generated and should not be edited manually: diff --git a/docs/site/cli/doctor.md b/docs/site/cli/doctor.md index 30c8b8a4..eb4ec397 100644 --- a/docs/site/cli/doctor.md +++ b/docs/site/cli/doctor.md @@ -16,11 +16,24 @@ sm doctor [--fix] | Option | Description | |--------|-------------| -| `--fix` | Auto-fix missing `.slnx` entries and project references | +| `--fix` | Auto-fix missing `.slnx` entries, host project references, `Pages/index.ts` entries, and root `package.json` npm workspace globs | ## Checks Performed -The doctor command runs five categories of checks: +The doctor command runs twelve checks, grouped below by concern: + +- **SolutionStructure** -- foundational directories and the `.slnx` solution file +- **ProjectReference** -- host project references each module's implementation +- **SlnxEntries** -- `.slnx` contains every module's contracts, implementation, and tests +- **CsprojConvention** -- `.csproj` files follow SimpleModule conventions +- **ContractsIsolation** -- contracts projects do not reference other modules +- **ModulePattern** -- module directory layout matches the expected pattern +- **ModuleAttribute** -- module class is decorated with `[Module]` +- **ViewEndpointNaming** -- view endpoints follow naming conventions +- **PagesRegistry** -- every view endpoint has an entry in `Pages/index.ts` +- **ViteConfig** -- module has a valid `vite.config.ts` +- **PackageJson** -- module has a valid `package.json` +- **NpmWorkspace** -- the root `package.json` workspaces array includes each module ### 1. Solution Structure @@ -76,6 +89,8 @@ When you pass `--fix`, the doctor attempts to repair the following issues: - **Missing `.slnx` entries** -- adds folder entries for the module's three projects - **Missing project references** -- adds a `` in the host `.csproj` pointing to the module +- **Missing `Pages/index.ts` entries** -- adds a registry entry for each view endpoint that lacks one +- **Missing npm workspace globs** -- adds the module's workspace paths to the root `package.json` After auto-fixing, all checks are re-run and the results table reflects the current state. @@ -87,7 +102,7 @@ sm doctor --fix ``` ::: info -Auto-fix only handles structural wiring (slnx entries and project references). It does not create missing files like `Module.cs` or `DbContext.cs` -- use `sm new module` for that. +Auto-fix handles structural wiring: `.slnx` entries, host project references, `Pages/index.ts` registry entries for view endpoints, and npm workspace globs in the root `package.json`. It does not create missing files like `Module.cs` or `DbContext.cs` -- use `sm new module` for that. ::: ## Output diff --git a/docs/site/cli/new-feature.md b/docs/site/cli/new-feature.md index 4de45754..ac0cb6ff 100644 --- a/docs/site/cli/new-feature.md +++ b/docs/site/cli/new-feature.md @@ -23,15 +23,21 @@ If you omit required options, the CLI prompts you interactively with selection m | `--method ` | HTTP method: `GET`, `POST`, `PUT`, or `DELETE`. Prompted if omitted. | | `-r, --route ` | Route pattern (e.g., `/{id}`). Defaults to `/{id}` when prompted. | | `--validator` | Include a request validator class alongside the endpoint. | +| `--no-view` | Skip creating the React view component and its `Pages/index.ts` entry. | +| `--dry-run` | Preview the files that would be created or modified without writing anything to disk. | ## What Gets Created Running `sm new feature UpdateInvoice --module Invoices --method PUT --route /{id} --validator` generates: ``` -modules/Invoices/src/Invoices/Endpoints/Invoices/ - UpdateInvoiceEndpoint.cs # IEndpoint implementation - UpdateInvoiceRequestValidator.cs # Request validator (when --validator is used) +src/modules/Invoices/src/Invoices/ + Endpoints/Invoices/ + UpdateInvoiceEndpoint.cs # IEndpoint implementation + UpdateInvoiceRequestValidator.cs # Request validator (when --validator is used) + Views/ + UpdateInvoice.tsx # React view component (unless --no-view) + Pages/index.ts # Updated with a new entry mapping "Invoices/UpdateInvoice" to the view ``` ### Endpoint Auto-Discovery @@ -42,6 +48,16 @@ The generated endpoint implements `IEndpoint`, which the Roslyn source generator When you pass `--validator`, the CLI generates a companion validator class that validates the request before the endpoint logic runs. +### View + Pages Registry (Default) + +By default the CLI also creates `Views/{Feature}.tsx` and appends an entry to the module's `Pages/index.ts`, e.g.: + +```ts +'Invoices/UpdateInvoice': () => import('@/Views/UpdateInvoice'), +``` + +Pass `--no-view` to skip both steps (useful for pure API endpoints that never render a page). + ## Interactive Mode When run without flags, the CLI walks you through each option: @@ -61,7 +77,7 @@ sm new feature - At least one module must exist. If no modules are found, the CLI directs you to run `sm new module` first ::: tip View Endpoints -If your feature is a page (view endpoint using `Inertia.Render`), remember to add a corresponding entry in your module's `Pages/index.ts`. See the [Pages Registry Pattern](/guide/modules#pages-registry) for details. +`sm new feature` automatically adds the `Pages/index.ts` entry for the view it scaffolds. If you later add an `IViewEndpoint` by hand (or pass `--no-view`), you must register it yourself. See the [Pages Registry Pattern](/guide/modules#pages-registry) for details. ::: ## Example diff --git a/docs/site/cli/new-module.md b/docs/site/cli/new-module.md index d3c0f7de..4dffb5a8 100644 --- a/docs/site/cli/new-module.md +++ b/docs/site/cli/new-module.md @@ -19,6 +19,7 @@ If you omit the name, the CLI prompts you interactively. | Option | Description | |--------|-------------| | `[name]` | Module name in PascalCase (e.g., `Invoices`). Must start with an uppercase letter. Prompted if omitted. | +| `--dry-run` | Preview the files that would be created without writing anything to disk. | ## What Gets Created @@ -27,7 +28,7 @@ Running `sm new module Invoices` generates the following structure. The CLI auto ### Contracts Project ``` -modules/Invoices/src/Invoices.Contracts/ +src/modules/Invoices/src/Invoices.Contracts/ Invoices.Contracts.csproj # References Core only IInvoiceContracts.cs # Public contract interface Invoice.cs # [Dto] type for cross-module use @@ -38,12 +39,13 @@ modules/Invoices/src/Invoices.Contracts/ ### Implementation Project ``` -modules/Invoices/src/Invoices/ +src/modules/Invoices/src/Invoices/ Invoices.csproj # References Core + Contracts InvoicesModule.cs # IModule with [Module("Invoices")] InvoicesConstants.cs # Module constants (permissions, etc.) InvoicesDbContext.cs # EF Core DbContext InvoiceService.cs # Service implementing IInvoiceContracts + tsconfig.json # TypeScript config for Views/Pages Endpoints/Invoices/ GetAllEndpoint.cs # IEndpoint (auto-discovered) ``` @@ -51,7 +53,7 @@ modules/Invoices/src/Invoices/ ### Test Project ``` -modules/Invoices/tests/Invoices.Tests/ +src/modules/Invoices/tests/Invoices.Tests/ Invoices.Tests.csproj # xUnit test project GlobalUsings.cs # Common test usings Unit/ @@ -87,7 +89,7 @@ After the CLI finishes: ```bash dotnet build # source generator discovers the new module -dotnet run --project src/MyApp.Api +dotnet run --project src/MyApp.Host ``` ::: tip diff --git a/docs/site/cli/new-project.md b/docs/site/cli/new-project.md index 02500cbf..2829de52 100644 --- a/docs/site/cli/new-project.md +++ b/docs/site/cli/new-project.md @@ -20,6 +20,8 @@ If you omit the name, the CLI prompts you interactively. |--------|-------------| | `[name]` | Project name in PascalCase (e.g., `MyApp`). Prompted if omitted. | | `-o, --output ` | Output directory. Defaults to the current directory. | +| `--dry-run` | Preview the files that would be created without writing anything to disk. | +| `--framework-version ` | Override the auto-resolved SimpleModule NuGet package version. | ## What Gets Created @@ -27,40 +29,50 @@ Running `sm new project MyApp` generates the following structure: ``` MyApp/ - MyApp.slnx # Solution file - Directory.Build.props # Shared MSBuild properties - Directory.Packages.props # Central package management - global.json # SDK version pinning + MyApp.slnx # Solution file + Directory.Build.props # Shared MSBuild properties + Directory.Packages.props # Central package management + global.json # SDK version pinning + nuget.config # NuGet feed configuration + package.json # npm workspace root + biome.json # Biome lint + format config + tsconfig.json # Shared TypeScript config + .editorconfig # Editor/style rules src/ - MyApp.Api/ - MyApp.Api.csproj # Host/API project - Program.cs # Entry point with generated extensions - MyApp.Core/ - MyApp.Core.csproj # Core framework (IModule, IEndpoint, etc.) - MyApp.Database/ - MyApp.Database.csproj # Database infrastructure - MyApp.Generator/ - MyApp.Generator.csproj # Roslyn source generator (netstandard2.0) - modules/ # Empty directory for modules + MyApp.Host/ + MyApp.Host.csproj # Host project (references framework NuGet packages) + Program.cs # Entry point with generated extensions + ClientApp/ # React + Inertia bootstrap + Styles/ # Tailwind entry + Properties/launchSettings.json + wwwroot/index.html # Inertia static shell + modules/ + Items/ # Starter module scaffolded by default + src/ + Items.Contracts/ + Items/ # Module, DbContext, service, endpoints, Views, Pages, vite.config, package.json + tests/ + Items.Tests/ tests/ MyApp.Tests.Shared/ - MyApp.Tests.Shared.csproj # Shared test infrastructure + MyApp.Tests.Shared.csproj # Shared test infrastructure ``` ## Project Details -- **Api** -- the host application that calls generated `AddModules()` and `MapModuleEndpoints()` extension methods -- **Core** -- defines the `IModule` interface, `[Module]` attribute, `IEndpoint`, `[Dto]`, events (`IEvent` + Wolverine), and menu system -- **Database** -- multi-provider database support with schema isolation per module -- **Generator** -- Roslyn incremental source generator targeting `netstandard2.0` for compile-time module discovery +- **Host** -- the host application that calls generated `AddModules()` and `MapModuleEndpoints()` extension methods; serves the Inertia + React frontend +- **Items module** -- a starter module under `src/modules/Items/` demonstrating the three-project pattern (contracts, implementation, tests) plus the frontend conventions (`Views/`, `Pages/index.ts`, `vite.config.ts`, `package.json`) - **Tests.Shared** -- `WebApplicationFactory` base class, fake data generators, and test authentication +Framework code (`Core`, `Database`, `Generator`) is consumed from NuGet packages rather than scaffolded into your repo. + ## After Scaffolding ```bash cd MyApp -sm new module Products # create your first module +sm new module Products # add another module under src/modules/ dotnet build # build the solution +dotnet run --project src/MyApp.Host ``` ::: warning diff --git a/docs/site/frontend/overview.md b/docs/site/frontend/overview.md index c071bfd8..877e9e18 100644 --- a/docs/site/frontend/overview.md +++ b/docs/site/frontend/overview.md @@ -4,7 +4,7 @@ outline: deep # Frontend Overview -SimpleModule's frontend is built on **React 19** served through **Inertia.js** with a **Blazor SSR** shell. Each module ships its own self-contained page bundle, and the ClientApp bootstraps Inertia to dynamically load pages from any module at runtime. +SimpleModule's frontend is built on **React 19** served through **Inertia.js** with a static HTML shell served by ASP.NET Core + Inertia.js middleware. Each module ships its own self-contained page bundle, and the ClientApp bootstraps Inertia to dynamically load pages from any module at runtime. ## Architecture @@ -13,8 +13,8 @@ The frontend architecture follows a modular pattern that mirrors the backend: ``` Browser Request --> ASP.NET route handler calls Inertia.Render("Products/Browse", props) - --> Inertia middleware renders Blazor SSR shell with JSON props - --> React ClientApp dynamically imports Products.pages.js + --> Inertia middleware renders static HTML shell with embedded JSON props + --> React ClientApp dynamically imports SimpleModule.Products.pages.js --> Component hydrates with server-provided props ``` @@ -36,12 +36,12 @@ Each module compiles its React pages into a single ES module bundle (`{ModuleNam Every module that has a UI builds a `{ModuleName}.pages.js` file into its `wwwroot/` directory. This file exports a `pages` record that maps route names to React components: ```ts -// modules/Products/src/Products/Pages/index.ts +// modules/Products/src/SimpleModule.Products/Pages/index.ts export const pages: Record = { - 'Products/Browse': () => import('../Views/Browse'), - 'Products/Manage': () => import('../Views/Manage'), - 'Products/Create': () => import('../Views/Create'), - 'Products/Edit': () => import('../Views/Edit'), + 'Products/Browse': () => import('./Browse'), + 'Products/Manage': () => import('./Manage'), + 'Products/Create': () => import('./Create'), + 'Products/Edit': () => import('./Edit'), }; ``` @@ -74,9 +74,24 @@ When Inertia needs to render a page, `resolvePage` splits the route name to dete // @simplemodule/client/resolve-page.ts export async function resolvePage(name: string) { const moduleName = name.split('/')[0]; - const mod = await import(`/_content/${moduleName}/${moduleName}.pages.js`); + const assemblyName = `SimpleModule.${moduleName}`; + const mod = await import(`/_content/${assemblyName}/${assemblyName}.pages.js`); + + if (!mod.pages) { + throw new Error( + `Module "${moduleName}" does not export a "pages" record. Check ${assemblyName}.pages.js.`, + ); + } + const page = mod.pages[name]; + if (!page) { + const available = Object.keys(mod.pages).join(', '); + throw new Error( + `Page "${name}" not found in module "${moduleName}". Available pages: ${available}.`, + ); + } + // Support lazy page entries: () => import('./SomePage') if (typeof page === 'function') { const resolved = await page(); @@ -89,7 +104,7 @@ export async function resolvePage(name: string) { For a route name like `Products/Browse`: 1. The module name `Products` is extracted from the first segment -2. The bundle `/_content/Products/Products.pages.js` is dynamically imported +2. The bundle `/_content/SimpleModule.Products/SimpleModule.Products.pages.js` is dynamically imported 3. The `pages` record is looked up for the key `Products/Browse` 4. Lazy entries (functions) are resolved, eager entries are returned directly diff --git a/docs/site/frontend/pages.md b/docs/site/frontend/pages.md index ceec9429..3180994c 100644 --- a/docs/site/frontend/pages.md +++ b/docs/site/frontend/pages.md @@ -11,12 +11,12 @@ Every module that renders UI must maintain a **pages registry** -- a `Pages/inde Each module exports a `pages` record from `Pages/index.ts`: ```ts -// modules/Products/src/Products/Pages/index.ts +// modules/Products/src/SimpleModule.Products/Pages/index.ts export const pages: Record = { - 'Products/Browse': () => import('../Views/Browse'), - 'Products/Manage': () => import('../Views/Manage'), - 'Products/Create': () => import('../Views/Create'), - 'Products/Edit': () => import('../Views/Edit'), + 'Products/Browse': () => import('./Browse'), + 'Products/Manage': () => import('./Manage'), + 'Products/Create': () => import('./Create'), + 'Products/Edit': () => import('./Edit'), }; ``` @@ -27,7 +27,7 @@ Each key matches the component name passed to `Inertia.Render()` on the server s On the backend, view endpoints call `Inertia.Render` with a component name: ```csharp -// modules/Products/src/Products/Views/BrowseEndpoint.cs +// modules/Products/src/SimpleModule.Products/Pages/BrowseEndpoint.cs public class BrowseEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) @@ -45,16 +45,17 @@ public class BrowseEndpoint : IViewEndpoint } ``` -The string `"Products/Browse"` is the key that `resolvePage` looks up in the module's `pages` record. If the key does not exist, the page silently fails to load. +The string `"Products/Browse"` is the key that `resolvePage` looks up in the module's `pages` record. If the key does not exist, `resolvePage` throws an explicit error and the ClientApp surfaces a toast notification. -::: danger Missing entries cause silent 404s +::: danger Missing entries throw an explicit error If you add a new `IViewEndpoint` with `Inertia.Render("Products/Something")` but forget to add a matching entry in `Pages/index.ts`: - The endpoint compiles and runs fine on the server -- Navigating to that page results in a **silent client-side 404** -- no error in the console, no error response shown to the user -- This can go unnoticed for hours until QA or a user discovers it +- Navigating to that page causes `resolvePage` to throw: + `Error: Page "Products/Something" not found in module "Products". Available pages: ...` +- The ClientApp surfaces this via a toast notification (not a silent 404), and the error is logged to the browser console -**Always add the pages registry entry immediately when creating a new view endpoint.** +**Always add the pages registry entry immediately when creating a new view endpoint** -- the error is visible, but it still breaks navigation for users. ::: ## The Rule @@ -62,7 +63,7 @@ If you add a new `IViewEndpoint` with `Inertia.Render("Products/Something")` but For every `IViewEndpoint` that calls `Inertia.Render("ModuleName/PageName", ...)`, there must be a matching entry in that module's `Pages/index.ts`: ```ts -'ModuleName/PageName': () => import('../Views/PageName'), +'ModuleName/PageName': () => import('./PageName'), ``` ## Adding a New Page Step-by-Step @@ -80,10 +81,10 @@ For every `IViewEndpoint` that calls `Inertia.Render("ModuleName/PageName", ...) } ``` -2. **Create the React component** in the module's `Views/` directory: +2. **Create the React component** in the module's `Pages/` directory: ```tsx - // modules/Products/src/Products/Views/Details.tsx + // modules/Products/src/SimpleModule.Products/Pages/Details.tsx import { PageShell } from '@simplemodule/ui'; import type { Product } from '../types'; @@ -100,11 +101,11 @@ For every `IViewEndpoint` that calls `Inertia.Render("ModuleName/PageName", ...) ```ts export const pages: Record = { - 'Products/Browse': () => import('../Views/Browse'), - 'Products/Manage': () => import('../Views/Manage'), - 'Products/Create': () => import('../Views/Create'), - 'Products/Edit': () => import('../Views/Edit'), - 'Products/Details': () => import('../Views/Details'), // [!code ++] + 'Products/Browse': () => import('./Browse'), + 'Products/Manage': () => import('./Manage'), + 'Products/Create': () => import('./Create'), + 'Products/Edit': () => import('./Edit'), + 'Products/Details': () => import('./Details'), // [!code ++] }; ``` @@ -169,7 +170,7 @@ The pages registry supports both lazy and eager component imports: ```ts // Lazy (recommended) -- component is loaded on demand -'Products/Browse': () => import('../Views/Browse'), +'Products/Browse': () => import('./Browse'), // Eager -- component is bundled into the pages.js file 'Products/Browse': Browse, diff --git a/docs/site/frontend/styling.md b/docs/site/frontend/styling.md index d995cfda..210a6d31 100644 --- a/docs/site/frontend/styling.md +++ b/docs/site/frontend/styling.md @@ -19,7 +19,7 @@ The global stylesheet lives at `template/SimpleModule.Host/Styles/app.css` and i @source "../../../modules/**/Pages/**/*.tsx"; ``` -The `@source` directives tell Tailwind where to scan for class usage, ensuring that utility classes used in module components, UI package files, and Blazor Razor components are all included in the final CSS output. +The `@source` directives tell Tailwind where to scan for class usage, ensuring that utility classes used in module components, UI package files, and React TSX components are all included in the final CSS output. ## The Default Theme @@ -31,11 +31,11 @@ The `@simplemodule/theme-default` package (`packages/SimpleModule.Theme.Default/ | Token | Light | Purpose | |---|---|---| -| `--color-primary` | `#16a34a` | Primary actions, links, focus rings | -| `--color-primary-hover` | `#15803d` | Primary hover state | -| `--color-primary-light` | `#4ade80` | Light primary variant | -| `--color-primary-subtle` | `rgba(22, 163, 74, 0.08)` | Subtle backgrounds | -| `--color-accent` | `#166534` | Gradient endpoints, deep emphasis | +| `--color-primary` | `#059669` | Primary actions, links, focus rings | +| `--color-primary-hover` | `#047857` | Primary hover state | +| `--color-primary-light` | `#34d399` | Light primary variant | +| `--color-primary-subtle` | `rgba(5, 150, 105, 0.08)` | Subtle backgrounds | +| `--color-accent` | `#0f766e` | Gradient endpoints, deep emphasis (teal) | #### Semantic Colors @@ -64,8 +64,8 @@ All tokens are accessible as Tailwind utilities. For example, `bg-surface`, `tex The theme defines themeable shadows used by button components: ```css ---shadow-primary: 0 4px 14px rgba(22, 163, 74, 0.35); ---shadow-primary-hover: 0 6px 20px rgba(22, 163, 74, 0.5); +--shadow-primary: 0 4px 14px rgba(5, 150, 105, 0.35); +--shadow-primary-hover: 0 6px 20px rgba(5, 150, 105, 0.5); --shadow-danger: 0 4px 14px rgba(225, 29, 72, 0.25); --shadow-danger-hover: 0 6px 20px rgba(225, 29, 72, 0.4); ``` diff --git a/docs/site/frontend/vite.md b/docs/site/frontend/vite.md index ea8ee22b..2a2f82cc 100644 --- a/docs/site/frontend/vite.md +++ b/docs/site/frontend/vite.md @@ -4,7 +4,7 @@ outline: deep # Vite Configuration -SimpleModule uses **Vite 6** in library mode to build each module's frontend as a standalone ES module. This page covers the build configuration, the development workflow, and the orchestrators that coordinate everything. +SimpleModule uses **Vite 8** (with the Rolldown bundler) in library mode to build each module's frontend as a standalone ES module. This page covers the build configuration, the development workflow, and the orchestrators that coordinate everything. ## Library Mode for Modules @@ -23,10 +23,10 @@ In a modular monolith, each module is independently deployable. If every module Every module uses the `defineModuleConfig` helper from `@simplemodule/client/module`: ```ts -// modules/Products/src/Products/vite.config.ts +// modules/Products/src/SimpleModule.Products/vite.config.ts import { defineModuleConfig } from '@simplemodule/client/module'; -export default defineModuleConfig(__dirname); +export default defineModuleConfig(import.meta.dirname); ``` This single line generates a complete Vite configuration. The helper derives everything from the module directory: @@ -34,10 +34,10 @@ This single line generates a complete Vite configuration. The helper derives eve | Setting | Value | Source | |---|---|---| | Entry point | `Pages/index.ts` | Convention | -| Output file | `{Name}.pages.js` | Directory name | +| Output file | `SimpleModule.{Name}.pages.js` | Directory name | | Output directory | `wwwroot/` | Convention | | Format | ES module | Fixed | -| Externals | React, React-DOM, Inertia | `defaultVendors` | +| Externals | `react`, `react-dom`, `react/jsx-runtime`, `react-dom/client`, `@inertiajs/react` | `defaultVendors` | | Source maps | Enabled in dev, disabled in prod | `VITE_MODE` env var | | Minification | Disabled in dev, esbuild in prod | `VITE_MODE` env var | @@ -65,7 +65,7 @@ function defineModuleConfig(dir: string): UserConfig { minify: isDev ? false : 'esbuild', outDir: 'wwwroot', emptyOutDir: false, - rollupOptions: { + rolldownOptions: { external: defaultVendors.map((v) => v.pkg), output: { assetFileNames: `${name.toLowerCase()}[extname]`, @@ -84,7 +84,7 @@ For non-standard requirements, use Vite's `mergeConfig`: import { mergeConfig } from 'vite'; import { defineModuleConfig } from '@simplemodule/client/module'; -export default mergeConfig(defineModuleConfig(__dirname), { +export default mergeConfig(defineModuleConfig(import.meta.dirname), { // Custom overrides here }); ``` @@ -130,11 +130,15 @@ Each module declares React and React-DOM as **peer dependencies** since they are } ``` -Three scripts are standard: +Three scripts are standard at the module level: - **`build`** -- Production build (minified, no source maps) - **`build:dev`** -- Development build (unminified, with source maps) - **`watch`** -- Development build with file watching +::: info Repo-level vs module-level scripts +The `dev:build` script only exists at the **repo root** (it invokes the build orchestrator to build every module in dev mode). Inside a module, use `build:dev` -- `npm run dev:build` at the module level will fail. +::: + ## Development Workflow ### `npm run dev` @@ -149,9 +153,9 @@ The `npm run dev` command starts the complete development environment using the npm run dev | ├── dotnet run --project template/SimpleModule.Host - ├── npm run watch (in modules/Products/src/Products/) - ├── npm run watch (in modules/Orders/src/Orders/) - ├── npm run watch (in modules/Users/src/Users/) + ├── npm run watch (in modules/Products/src/SimpleModule.Products/) + ├── npm run watch (in modules/Orders/src/SimpleModule.Orders/) + ├── npm run watch (in modules/Users/src/SimpleModule.Users/) └── npm run watch (in template/SimpleModule.Host/ClientApp/) ``` @@ -218,12 +222,12 @@ npm run dev:build After building, each module's `wwwroot/` directory contains: ``` -modules/Products/src/Products/wwwroot/ - Products.pages.js # The module's page bundle - products.css # Any CSS assets (named from module) +modules/Products/src/SimpleModule.Products/wwwroot/ + SimpleModule.Products.pages.js # The module's page bundle + simplemodule.products.css # Any CSS assets (named from module) ``` -These files are served as static content via ASP.NET's `_content/{ModuleName}/` path, which is how `resolvePage` finds them at `/_content/Products/Products.pages.js`. +These files are served as static content via ASP.NET's `_content/SimpleModule.{ModuleName}/` path, which is how `resolvePage` finds them at `/_content/SimpleModule.Products/SimpleModule.Products.pages.js`. ## Next Steps diff --git a/docs/site/getting-started/project-structure.md b/docs/site/getting-started/project-structure.md index 4e12bc1d..6e55ebc4 100644 --- a/docs/site/getting-started/project-structure.md +++ b/docs/site/getting-started/project-structure.md @@ -6,10 +6,47 @@ outline: deep A SimpleModule solution follows a consistent directory layout that separates the framework, your feature modules, frontend packages, and the host application. -## Top-Level Layout +::: info Two layouts +This page shows two different directory layouts: + +- **CLI-scaffolded projects** (what `sm new project` generates for you) use `src/modules/` for feature modules. +- **The SimpleModule framework repository** itself uses `modules/` at the repo root. + +The examples below are labelled so you know which is which. If you are building an application with SimpleModule, the CLI-scaffolded layout is the one that matters. +::: + +## Top-Level Layout (CLI-scaffolded project) + +When you run `sm new project MyApp`, the resulting solution looks like this: ``` MyApp/ +├── src/ +│ ├── modules/ # Your feature modules +│ │ ├── SimpleModule.Products/ +│ │ │ ├── src/ +│ │ │ │ ├── SimpleModule.Products/ +│ │ │ │ └── SimpleModule.Products.Contracts/ +│ │ │ └── tests/ +│ │ │ └── SimpleModule.Products.Tests/ +│ │ └── ... +│ └── MyApp.Host/ # Host application +│ ├── ClientApp/ +│ ├── Program.cs +│ └── wwwroot/ +├── MyApp.slnx # Solution file +├── package.json # Root npm workspace config +└── Directory.Build.props # Shared MSBuild properties +``` + +The CLI consumes the framework packages from NuGet, so `framework/`, `packages/`, and `cli/` are not present in a scaffolded app. + +## Top-Level Layout (framework repository) + +The SimpleModule framework repository itself lays the source out differently, because it hosts the framework packages alongside a reference host and demo modules: + +``` +SimpleModule/ ├── framework/ # Core framework packages │ ├── SimpleModule.Core/ │ ├── SimpleModule.Generator/ @@ -28,15 +65,19 @@ MyApp/ │ ├── SimpleModule.Storage.Local/ # Local filesystem storage │ ├── SimpleModule.Storage.S3/ # AWS S3 storage │ └── SimpleModule.Storage.Azure/ # Azure Blob storage -├── modules/ # Your feature modules +├── modules/ # Demo / built-in modules (framework repo only) │ ├── Admin/ │ ├── Agents/ │ ├── AuditLogs/ │ ├── BackgroundJobs/ +│ ├── Chat/ │ ├── Dashboard/ +│ ├── Datasets/ +│ ├── Email/ │ ├── FeatureFlags/ │ ├── FileStorage/ │ ├── Localization/ +│ ├── Map/ │ ├── Marketplace/ │ ├── OpenIddict/ │ ├── Orders/ @@ -44,15 +85,17 @@ MyApp/ │ ├── Permissions/ │ ├── Products/ │ ├── Rag/ +│ ├── RateLimiting/ │ ├── Settings/ │ ├── Tenants/ │ └── Users/ ├── packages/ # Frontend npm packages │ ├── SimpleModule.Client/ │ ├── SimpleModule.UI/ -│ └── SimpleModule.Theme.Default/ +│ ├── SimpleModule.Theme.Default/ +│ └── SimpleModule.TsConfig/ ├── template/ -│ └── SimpleModule.Host/ # The host application +│ └── SimpleModule.Host/ # Reference host application │ ├── ClientApp/ │ ├── Program.cs │ └── wwwroot/ @@ -94,13 +137,17 @@ It generates: | Generated method | Purpose | |-----------------|---------| -| `AddModules()` | Calls each module's `ConfigureServices` | -| `MapModuleEndpoints()` | Maps all discovered endpoints with route prefixes | -| `CollectModuleMenuItems()` | Builds the navigation menu from module registrations | +| `AddModules()` | Calls each module's `ConfigureServices`. Invoked by `builder.AddSimpleModule()`. | +| `MapModuleEndpoints()` | Maps all discovered endpoints with route prefixes. Invoked by `app.UseSimpleModule()`. | +| `CollectModuleMenuItems()` | Builds the navigation menu from module registrations. Invoked by `app.UseSimpleModule()`. | | JSON serializer contexts | AOT-friendly serialization for `[Dto]` types | | TypeScript interfaces | Embedded TS definitions extracted by build tooling | | View page registry | Maps view endpoints to React components | +::: tip User-facing entrypoints +You call `builder.AddSimpleModule()` and `await app.UseSimpleModule()` from your host. These wrappers in `SimpleModule.Hosting` delegate to the generated methods above, so you do not invoke `AddModules()`, `MapModuleEndpoints()`, or `CollectModuleMenuItems()` directly. +::: + ::: tip Inspecting Generated Code In Visual Studio or Rider, expand **Dependencies > Analyzers > SimpleModule.Generator** to see exactly what code the generator produces. This is useful for debugging registration issues. ::: @@ -115,7 +162,7 @@ Multi-provider database support built on EF Core. Handles: ### SimpleModule.Hosting -Module registration infrastructure and Inertia page rendering. Provides the runtime plumbing that the generated `AddModules()` and `MapModuleEndpoints()` methods call into. Handles service collection extensions, endpoint routing integration, module lifecycle management, and renders the static HTML shell with embedded JSON props for React hydration. +Module registration infrastructure and Inertia page rendering. Exposes the two user-facing entry points that your host's `Program.cs` calls -- `builder.AddSimpleModule()` and `await app.UseSimpleModule()` -- which in turn invoke the generated `AddModules()`, `MapModuleEndpoints()`, and `CollectModuleMenuItems()` methods. Handles service collection extensions, endpoint routing integration, module lifecycle management, and renders the static HTML shell with embedded JSON props for React hydration. ### SimpleModule.Agents @@ -152,59 +199,67 @@ The `tools/` directory holds non-module .NET utilities consumed by the host or t ### SimpleModule.DevTools -Development utilities including hot reload support, diagnostic middleware, and developer experience tooling. Wired into the host only when `builder.Environment.IsDevelopment()` is true, so it is excluded from production. +Development utilities including hot reload support, diagnostic middleware, and developer experience tooling. The host does not need explicit dev-only wiring in `Program.cs` -- DevTools is imported via the `SimpleModule.Hosting.targets` MSBuild import in the host's csproj and activates automatically in development builds. ## Module Structure -Every module follows a **three-project pattern**: implementation, contracts, and tests. +Every module follows a **three-project pattern**: implementation, contracts, and tests. Project directories and assembly names use the `SimpleModule.{Name}` prefix (enforced by diagnostic SM0052). ``` -modules/Products/ +modules/Products/ # (framework repo layout -- use src/modules/SimpleModule.Products/ in a CLI-scaffolded app) ├── src/ -│ ├── Products/ # Implementation (private) -│ │ ├── Products.csproj -│ │ ├── ProductsModule.cs # Module class with [Module] attribute -│ │ ├── Data/ -│ │ │ ├── ProductsDbContext.cs # EF Core DbContext -│ │ │ └── Entities/ # Database entities (internal) +│ ├── SimpleModule.Products/ # Implementation (private) +│ │ ├── SimpleModule.Products.csproj +│ │ ├── ProductsModule.cs # Module class with [Module] attribute +│ │ ├── ProductsDbContext.cs # EF Core DbContext (module root) +│ │ ├── ProductService.cs # IProductContracts implementation (module root) +│ │ ├── EntityConfigurations/ # IEntityTypeConfiguration classes │ │ ├── Endpoints/ │ │ │ └── Products/ -│ │ │ ├── BrowseProducts.cs # GET /products -│ │ │ ├── CreateProduct.cs # POST /products/create -│ │ │ └── ManageProduct.cs # GET /products/{id} -│ │ ├── Services/ -│ │ │ └── ProductService.cs # IProductContracts implementation -│ │ ├── Pages/ -│ │ │ └── index.ts # React page registry -│ │ ├── Views/ -│ │ │ ├── Browse.tsx # React page components +│ │ │ ├── BrowseProducts.cs # GET /products +│ │ │ ├── CreateProduct.cs # POST /products/create +│ │ │ └── ManageProduct.cs # GET /products/{id} +│ │ ├── Pages/ # React components live alongside their view endpoints +│ │ │ ├── index.ts # React page registry +│ │ │ ├── Browse.tsx # React page component +│ │ │ ├── BrowseEndpoint.cs # Matching IViewEndpoint │ │ │ ├── Create.tsx -│ │ │ └── Manage.tsx -│ │ ├── vite.config.ts # Vite library mode config -│ │ └── package.json # npm package with peer deps -│ └── Products.Contracts/ # Public API (shared) -│ ├── Products.Contracts.csproj -│ ├── IProductContracts.cs # Contract interface -│ └── Dtos/ -│ └── ProductDto.cs # [Dto] types +│ │ │ ├── CreateEndpoint.cs +│ │ │ ├── Edit.tsx +│ │ │ ├── EditEndpoint.cs +│ │ │ ├── Manage.tsx +│ │ │ └── ManageEndpoint.cs +│ │ ├── vite.config.ts # Vite library mode config +│ │ └── package.json # npm package with peer deps +│ └── SimpleModule.Products.Contracts/ # Public API (shared) +│ ├── SimpleModule.Products.Contracts.csproj +│ ├── IProductContracts.cs # Contract interface +│ ├── Product.cs # [Dto] public record +│ ├── CreateProductRequest.cs # [Dto] request shape +│ ├── UpdateProductRequest.cs # [Dto] request shape +│ ├── ProductId.cs # Strongly-typed id +│ ├── ProductsConstants.cs # Shared constants +│ └── Events/ # Cross-module event records └── tests/ - └── Products.Tests/ # Test project - ├── Products.Tests.csproj + └── SimpleModule.Products.Tests/ # Test project + ├── SimpleModule.Products.Tests.csproj └── Endpoints/ └── BrowseProductsTests.cs ``` -### Implementation Project (`Products/`) +There is no separate `Views/` directory -- React components (`*.tsx`) live directly in `Pages/` next to their matching `*Endpoint.cs` view endpoints. Likewise the DbContext and the contracts service implementation sit at the module root rather than inside `Data/` or `Services/` folders. + +### Implementation Project (`SimpleModule.Products/`) This is the private implementation. No other module should reference this project directly. It contains: - **Module class** -- the `[Module]`-decorated class that registers services - **Endpoints** -- classes implementing `IEndpoint` or `IViewEndpoint`, auto-discovered by the generator -- **Data layer** -- EF Core DbContext, entities, and migrations (all `internal`) -- **Services** -- implementations of the contract interface +- **Data layer** -- EF Core DbContext at the module root with `EntityConfigurations/` for `IEntityTypeConfiguration` classes (all `internal`) +- **Services** -- implementation of the contract interface, kept at the module root - **Frontend** -- React pages, Vite config, and the page registry -The `.csproj` file uses `Microsoft.NET.Sdk` with a framework reference to ASP.NET: +The `.csproj` file uses `Microsoft.NET.Sdk` with a framework reference to ASP.NET and references `SimpleModule.Hosting` (which transitively brings in `SimpleModule.Core`). Real modules reference `SimpleModule.Hosting` rather than `SimpleModule.Core` directly: ```xml @@ -214,34 +269,36 @@ The `.csproj` file uses `Microsoft.NET.Sdk` with a framework reference to ASP.NE - - + + ``` -### Contracts Project (`Products.Contracts/`) +### Contracts Project (`SimpleModule.Products.Contracts/`) -The public face of the module. Other modules depend on this project when they need to interact with Products. It contains only: +The public face of the module. Other modules depend on this project when they need to interact with Products. Types live at the root of the contracts project (no `Dtos/` folder). It contains: - **Contract interface** (`IProductContracts`) -- methods other modules can call -- **DTO types** marked with `[Dto]` -- data shapes shared across module boundaries +- **Public record types** marked with `[Dto]` -- `Product`, `CreateProductRequest`, `UpdateProductRequest`, strongly-typed ids such as `ProductId`, and shared constants in `ProductsConstants` +- **`Events/`** -- cross-module event records published through the event bus ```csharp // IProductContracts.cs public interface IProductContracts { - Task> GetAllAsync(CancellationToken cancellationToken); - Task GetByIdAsync(int id, CancellationToken cancellationToken); - Task CreateAsync(CreateProductRequest request, CancellationToken cancellationToken); + Task> GetAllAsync(CancellationToken cancellationToken); + Task GetByIdAsync(ProductId id, CancellationToken cancellationToken); + Task CreateAsync(CreateProductRequest request, CancellationToken cancellationToken); } ``` ```csharp -// Dtos/ProductDto.cs +// Product.cs [Dto] -public sealed record ProductDto(int Id, string Name, decimal Price, string? Description); +public sealed record Product(ProductId Id, string Name, decimal Price, string? Description); +// CreateProductRequest.cs [Dto] public sealed record CreateProductRequest(string Name, decimal Price, string? Description); ``` @@ -250,7 +307,7 @@ public sealed record CreateProductRequest(string Name, decimal Price, string? De The contracts project must never reference the implementation project. It depends only on `SimpleModule.Core`. This ensures that modules cannot access each other's internals -- the compiler enforces the boundary. ::: -### Test Project (`Products.Tests/`) +### Test Project (`SimpleModule.Products.Tests/`) An xUnit.v3 test project with access to the shared test infrastructure: @@ -282,26 +339,24 @@ The host app lives at `template/SimpleModule.Host/` and is the entry point that ### Program.cs -The host's `Program.cs` calls the generated extension methods: +The host's `Program.cs` calls two user-facing entry points provided by `SimpleModule.Hosting`: ```csharp var builder = WebApplication.CreateBuilder(args); -// Generated: registers all module services -builder.AddModules(); +// Registers all module services (calls the generated AddModules() internally) +builder.AddSimpleModule(); var app = builder.Build(); -// Generated: maps all discovered endpoints with route prefixes -app.MapModuleEndpoints(); - -// Generated: collects menu items from all modules -app.CollectModuleMenuItems(); +// Maps all discovered endpoints and collects module menu items +// (calls the generated MapModuleEndpoints() and CollectModuleMenuItems() internally) +await app.UseSimpleModule(); app.Run(); ``` -These three method calls replace what would otherwise be dozens of manual registration lines. The source generator produces them based on what it discovers in your module assemblies. +`AddSimpleModule` and `UseSimpleModule` wrap the generated `AddModules()`, `MapModuleEndpoints()`, and `CollectModuleMenuItems()` methods, so these two calls replace what would otherwise be dozens of manual registration lines. The source generator produces the underlying methods based on what it discovers in your module assemblies. ### ClientApp @@ -354,6 +409,10 @@ Provides pre-built, accessible components: buttons, dialogs, tables, forms, drop The default Tailwind CSS theme. Provides base styles, color tokens, and design system foundations that the UI components and your module pages consume. +### @simplemodule/tsconfig + +Shared TypeScript base configuration (`packages/SimpleModule.TsConfig`) that modules and the host app extend from. Keeps compiler options consistent across every workspace. + ## Cross-Module Communication Modules communicate through two mechanisms: @@ -363,8 +422,8 @@ Modules communicate through two mechanisms: Module A depends on Module B's contracts project and calls its interface methods: ``` -modules/Orders/src/Orders.csproj - └── references → modules/Products/src/Products.Contracts/ +modules/Orders/src/SimpleModule.Orders/SimpleModule.Orders.csproj + └── references → modules/Products/src/SimpleModule.Products.Contracts/ ``` ```csharp @@ -430,11 +489,18 @@ Shared MSBuild properties applied to all projects in the solution: ### npm Workspaces -The root `package.json` defines npm workspaces covering: +The root `package.json` enumerates workspaces explicitly rather than using a blanket `packages/*` glob. In the framework repo the list is: - `modules/*/src/*` -- module frontend code -- `packages/*` -- shared frontend packages +- `packages/SimpleModule.Client` +- `packages/SimpleModule.Theme.Default` +- `packages/SimpleModule.TsConfig` +- `packages/SimpleModule.UI` - `template/SimpleModule.Host/ClientApp` -- the host app's React entry point +- `tests/e2e` +- `tests/k6` +- `docs/site` +- `website` This allows a single `npm install` at the root to resolve all dependencies, and commands like `npm run build` to build everything. @@ -442,10 +508,10 @@ This allows a single `npm install` at the root to resolve all dependencies, and If you prefer not to use the CLI, here are the steps: -1. Create the directory structure under `modules//` -2. Create the contracts project (`.Contracts.csproj`) referencing only `SimpleModule.Core` -3. Create the implementation project (`.csproj`) referencing Core and Contracts, with `` -4. Create the test project (`.Tests.csproj`) +1. Create the directory structure under `src/modules/SimpleModule./` (CLI-scaffolded app) or `modules//` (framework repo) +2. Create the contracts project (`SimpleModule..Contracts.csproj`) referencing only `SimpleModule.Core` +3. Create the implementation project (`SimpleModule..csproj`) referencing `SimpleModule.Hosting` and the contracts project, with `` +4. Create the test project (`SimpleModule..Tests.csproj`) 5. Add a `[Module]` class implementing `IModule` 6. Add endpoints implementing `IEndpoint` or `IViewEndpoint` 7. Set up the frontend: `package.json`, `vite.config.ts`, `Pages/index.ts`, React components diff --git a/docs/site/guide/ai-agents.md b/docs/site/guide/ai-agents.md index a03822ed..3834870e 100644 --- a/docs/site/guide/ai-agents.md +++ b/docs/site/guide/ai-agents.md @@ -35,6 +35,10 @@ builder.Services.AddAnthropicAI(builder.Configuration); } ``` +::: warning Model selection not yet wired +`AnthropicOptions.Model` is bound from configuration, but `AddAnthropicAI` currently constructs the client with only the API key and returns `client.Messages` as the `IChatClient`. The configured `Model` value is not passed to the client today — model selection happens at call time or is left to the SDK default. Track this if you rely on the configured model taking effect. +::: + ### OpenAI ```csharp @@ -80,8 +84,8 @@ builder.Services.AddOllamaAI(builder.Configuration); { "AI": { "Ollama": { - "BaseUrl": "http://localhost:11434", - "Model": "llama3" + "Endpoint": "http://localhost:11434", + "Model": "llama3.2" } } } @@ -93,12 +97,16 @@ builder.Services.AddOllamaAI(builder.Configuration); builder.Services.AddSimpleModuleAgents(builder.Configuration); ``` -This registers: +`AddSimpleModuleAgents` itself registers only: + +- `AgentOptions` -- bound from the `Agents:` configuration section - `AgentChatService` -- handles chat requests (streaming and non-streaming) -- `IAgentRegistry` -- discovers and serves agent definitions -- `IAgentSessionStore` -- persists conversation history -- Middleware pipeline: logging, rate limiting, token tracking, retry -- Guardrails: content length limits, PII redaction, prompt injection detection + +The rest of the stack is wired in separately: + +- **`IAgentRegistry`** and concrete `IAgentDefinition` implementations are registered by the source generator (`AgentExtensionsEmitter`) when it discovers agents in referenced assemblies. +- **`IAgentSessionStore`** lives in the separate `SimpleModule.Agents.Module` package; add that module if you need persisted conversation history. +- **`IAgentMiddleware`** and **`IAgentGuardrail`** are contracts only. `AgentMiddlewarePipeline` runs whatever implementations you register in DI — no middleware or guardrails ship as defaults. Register your own (for logging, rate limiting, PII redaction, etc.) explicitly. ## Defining an Agent @@ -116,7 +124,7 @@ public class ProductAssistant : IAgentDefinition // Optional overrides public int? MaxTokens => 2048; - public double? Temperature => 0.5; + public float? Temperature => 0.5f; public bool? EnableRag => true; public string? RagCollectionName => "products"; } @@ -203,19 +211,39 @@ builder.Services.AddInMemoryVectorStore(); builder.Services.AddPostgresVectorStore(builder.Configuration); ``` -## Agent Settings +## Agent Configuration + +`AddSimpleModuleAgents` binds `AgentOptions` from the `Agents:` configuration section. These are plain `IOptions` values — **not** admin-UI `SettingDefinition`s — so they are configured via `appsettings.json` (or any standard configuration source) and only take effect on app start / options reload. + +| Key | Default | Description | +|-----|---------|-------------| +| `Agents:Enabled` | `true` | Global kill switch | +| `Agents:MaxTokens` | `4096` | Default max tokens per response | +| `Agents:Temperature` | `0.7` | Default sampling temperature (`float`) | +| `Agents:EnableRag` | `true` | Enable RAG context injection | +| `Agents:EnableStreaming` | `true` | Allow streaming responses | +| `Agents:SessionTimeout` | `00:30:00` | Idle timeout for agent sessions | +| `Agents:RateLimit:RequestsPerMinute` | `60` | Rate limit per user | +| `Agents:RateLimit:TokensPerMinute` | `100000` | Token rate limit per user | -The module registers these settings (manageable via the admin UI): +Example: -| Setting | Default | Description | -|---------|---------|-------------| -| `Agents.Enabled` | `true` | Global kill switch | -| `Agents.MaxTokens` | `4096` | Default max tokens per response | -| `Agents.Temperature` | `0.7` | Default sampling temperature | -| `Agents.EnableRag` | `true` | Enable RAG context injection | -| `Agents.EnableStreaming` | `true` | Allow streaming responses | -| `Agents.RateLimit.RequestsPerMinute` | `60` | Rate limit per user | -| `Agents.RateLimit.TokensPerMinute` | `100000` | Token rate limit per user | +```json +{ + "Agents": { + "Enabled": true, + "MaxTokens": 4096, + "Temperature": 0.7, + "EnableRag": true, + "EnableStreaming": true, + "SessionTimeout": "00:30:00", + "RateLimit": { + "RequestsPerMinute": 60, + "TokensPerMinute": 100000 + } + } +} +``` ## Next Steps diff --git a/docs/site/guide/background-jobs.md b/docs/site/guide/background-jobs.md index 7daac4f4..bbd2597c 100644 --- a/docs/site/guide/background-jobs.md +++ b/docs/site/guide/background-jobs.md @@ -4,7 +4,7 @@ outline: deep # Background Jobs -SimpleModule provides a background job system built on [TickerQ](https://github.com/nickofc/TickerQ) for scheduling and executing long-running tasks outside the HTTP request pipeline. Jobs support progress reporting, structured logging, retries, and CRON-based recurring schedules. +SimpleModule provides a background job system for scheduling and executing long-running tasks outside the HTTP request pipeline. It is built in-house on top of a database-backed queue (`DatabaseJobQueue`), a worker hosted service (`JobProcessorService`), a stalled-job sweeper (`StalledJobSweeperService`), and [Cronos](https://github.com/HangfireIO/Cronos) for CRON expression parsing. Jobs support progress reporting, structured logging, retries, and CRON-based recurring schedules. ## Defining a Job @@ -110,15 +110,17 @@ Progress updates are batched and flushed to the database periodically (configura All endpoints require `BackgroundJobs.ViewJobs` or `BackgroundJobs.ManageJobs` permissions. +The module mounts its endpoints under `RoutePrefix = "/api/jobs"`. Route IDs are `guid`-constrained. + | Method | Route | Description | |--------|-------|-------------| | `GET` | `/api/jobs/` | List jobs with optional state/type filters | -| `GET` | `/api/jobs/{id}` | Job detail with logs and progress | -| `POST` | `/api/jobs/{id}/cancel` | Cancel a pending or running job | -| `POST` | `/api/jobs/{id}/retry` | Retry a failed job | +| `GET` | `/api/jobs/{id:guid}` | Job detail with logs and progress | +| `POST` | `/api/jobs/{id:guid}/cancel` | Cancel a pending or running job | +| `POST` | `/api/jobs/{id:guid}/retry` | Retry a failed job | | `GET` | `/api/jobs/recurring` | List all recurring jobs | -| `POST` | `/api/jobs/recurring/{id}/toggle` | Enable/disable a recurring job | -| `DELETE` | `/api/jobs/recurring/{id}` | Delete a recurring job | +| `POST` | `/api/jobs/recurring/{id:guid}/toggle` | Enable/disable a recurring job | +| `DELETE` | `/api/jobs/recurring/{id:guid}` | Delete a recurring job | ## Admin UI @@ -132,8 +134,15 @@ The module includes four admin pages at `/admin/jobs`: ## Configuration ```csharp +public enum BackgroundJobsWorkerMode +{ + Producer = 0, + Consumer = 1, +} + public class BackgroundJobsModuleOptions : IModuleOptions { + public BackgroundJobsWorkerMode WorkerMode { get; set; } = BackgroundJobsWorkerMode.Producer; public int MaxConcurrency { get; set; } = Environment.ProcessorCount; public int ProgressFlushBatchSize { get; set; } = 50; public TimeSpan ProgressFlushInterval { get; set; } = TimeSpan.FromSeconds(2); @@ -141,6 +150,15 @@ public class BackgroundJobsModuleOptions : IModuleOptions } ``` +### Worker Mode and Split Deployments + +`WorkerMode` controls whether this host actually executes jobs. This matters for split deployments where the web tier enqueues work but a separate worker tier processes it. + +- **`Producer`** (default) — the host can enqueue, schedule, and query jobs, but does **not** run `JobProcessorService` or `StalledJobSweeperService`. Use this for web-only instances. +- **`Consumer`** — the host registers the worker identity and runs `JobProcessorService` + `StalledJobSweeperService`, picking up and executing queued jobs. Use this for dedicated worker processes. + +`ProgressFlushService` runs in both modes so that any host owning the module can flush queued progress updates. + ## Contract Interface Query job data from other modules via `IBackgroundJobsContracts`: @@ -148,10 +166,11 @@ Query job data from other modules via `IBackgroundJobsContracts`: ```csharp public interface IBackgroundJobsContracts { - Task> GetJobsAsync(JobFilter filter, CancellationToken ct); - Task GetJobDetailAsync(JobId id, CancellationToken ct); - Task> GetRecurringJobsAsync(CancellationToken ct); - Task RetryAsync(JobId id, CancellationToken ct); + Task> GetJobsAsync(JobFilter filter, CancellationToken ct = default); + Task GetJobDetailAsync(JobId id, CancellationToken ct = default); + Task> GetRecurringJobsAsync(CancellationToken ct = default); + Task GetRecurringCountAsync(CancellationToken ct = default); + Task RetryAsync(JobId id, CancellationToken ct = default); } ``` diff --git a/docs/site/guide/contracts.md b/docs/site/guide/contracts.md index ae2a9d06..66e58ed7 100644 --- a/docs/site/guide/contracts.md +++ b/docs/site/guide/contracts.md @@ -247,9 +247,9 @@ The TypeScript generation pipeline converts C# DTO types into TypeScript interfa 1. **Source generator** scans for `[Dto]` types and public types in `*.Contracts` assemblies 2. **TypeScript definitions are embedded** in the generated source as string resources -3. **`extract-ts-types.mjs`** extracts the definitions and writes `.ts` files to each module +3. **`extract-ts-types.mjs`** extracts the definitions and writes a single `types.ts` per module -The generated TypeScript files are placed in each module's source directory as `types.ts`: +The generated TypeScript files are placed in each module's primary source project (e.g. `modules/Products/src/SimpleModule.Products/types.ts`): ```typescript // Auto-generated from [Dto] types -- do not edit diff --git a/docs/site/guide/database.md b/docs/site/guide/database.md index 6530157e..125c8283 100644 --- a/docs/site/guide/database.md +++ b/docs/site/guide/database.md @@ -140,6 +140,7 @@ public static void ApplyModuleSchema( if (hasOwnConnection) return; // Module has its own database, no prefix needed + var connectionString = dbOptions.DefaultConnection; var provider = DatabaseProviderDetector.Detect(connectionString); if (provider == DatabaseProvider.Sqlite) @@ -163,9 +164,13 @@ public static void ApplyModuleSchema( entity.SetSchema(schema); } } + + ApplyEntityConventions(modelBuilder, provider); } ``` +After partitioning tables, `ApplyModuleSchema` calls a private `ApplyEntityConventions(modelBuilder, provider)` helper that walks every entity type and wires up framework conventions: soft-delete query filters for `ISoftDelete`, concurrency tokens for `IHasConcurrencyStamp` and `IVersioned`, and a provider-appropriate column type for `IHasExtraProperties` (`jsonb` on PostgreSQL, `nvarchar(max)` on SQL Server, `TEXT` on SQLite). The helper is guarded against re-entry so it runs at most once per model. + ## Entity Configurations Use `IEntityTypeConfiguration` to define entity mappings. Keep these in an `EntityConfigurations` directory in your module: diff --git a/docs/site/guide/endpoints.md b/docs/site/guide/endpoints.md index b80f2321..36d27109 100644 --- a/docs/site/guide/endpoints.md +++ b/docs/site/guide/endpoints.md @@ -438,7 +438,7 @@ if (!validation.IsValid) By convention, endpoints are organized in the module's directory structure: ``` -modules/Products/src/Products/ +modules/Products/src/SimpleModule.Products/ Endpoints/ Products/ GetAllEndpoint.cs @@ -448,15 +448,20 @@ modules/Products/src/Products/ UpdateEndpoint.cs UpdateRequestValidator.cs # AbstractValidator DeleteEndpoint.cs - Views/ - BrowseEndpoint.cs + Pages/ + BrowseEndpoint.cs # IViewEndpoint — sits next to Browse.tsx + Browse.tsx ManageEndpoint.cs + Manage.tsx CreateEndpoint.cs + Create.tsx EditEndpoint.cs + Edit.tsx + index.ts # Pages registry ``` - `Endpoints/` contains `IEndpoint` classes (API), organized by resource -- `Views/` contains `IViewEndpoint` classes (Inertia pages) +- `Pages/` contains `IViewEndpoint` classes (Inertia pages) alongside their React `.tsx` components — there is no separate `Views/` directory - Validators sit alongside their corresponding endpoint ## Next Steps diff --git a/docs/site/guide/file-storage.md b/docs/site/guide/file-storage.md index 18176d9d..3c9698b2 100644 --- a/docs/site/guide/file-storage.md +++ b/docs/site/guide/file-storage.md @@ -15,11 +15,17 @@ All providers implement a common interface: ```csharp public interface IStorageProvider { - Task SaveAsync(string path, Stream content, string contentType); - Task GetAsync(string path); - Task DeleteAsync(string path); - Task ExistsAsync(string path); - Task> ListAsync(string prefix); + Task SaveAsync( + string path, + Stream content, + string contentType, + CancellationToken cancellationToken = default); + Task GetAsync(string path, CancellationToken cancellationToken = default); + Task DeleteAsync(string path, CancellationToken cancellationToken = default); + Task ExistsAsync(string path, CancellationToken cancellationToken = default); + Task> ListAsync( + string prefix, + CancellationToken cancellationToken = default); } ``` @@ -57,14 +63,14 @@ builder.Services.AddS3Storage(builder.Configuration); "AccessKey": "your-access-key", "SecretKey": "your-secret-key", "Region": "us-east-1", - "ServiceUrl": "", + "ServiceUrl": null, "ForcePathStyle": false } } } ``` -Set `ServiceUrl` for S3-compatible services (MinIO, DigitalOcean Spaces). Set `ForcePathStyle` to `true` for path-style URL access. +`ServiceUrl` is typed as `Uri?` — supply a valid URI (e.g., `"https://nyc3.digitaloceanspaces.com"`) or leave it as `null` to use the default AWS endpoint for the region. An empty string will not bind. Set `ForcePathStyle` to `true` for path-style URL access on S3-compatible services (MinIO, DigitalOcean Spaces). ### Azure Blob Storage @@ -101,7 +107,7 @@ The FileStorage module provides HTTP endpoints and a database-backed file regist ### Browse UI -A file browser view at `/files/browse` lets users navigate folders, upload files, and download or delete existing files. +A file browser view at `/files/` lets users navigate folders, upload files, and download or delete existing files. (The module uses `ViewPrefix = "/files"` with the browse endpoint mounted at `/`.) ### Module Settings @@ -117,13 +123,19 @@ Inject `IFileStorageContracts` to interact with file storage from any module: ```csharp public interface IFileStorageContracts { - Task> GetFilesAsync(string? folder = null); + Task> GetFilesAsync(string? folder = null, string? userId = null); Task GetFileByIdAsync(FileStorageId id); Task UploadFileAsync( - Stream content, string fileName, string contentType, string? folder = null); + Stream content, + string fileName, + string contentType, + string? folder = null, + string? userId = null); Task DeleteFileAsync(FileStorageId id); + Task DeleteFileAsync(StoredFile file); Task DownloadFileAsync(FileStorageId id); - Task> GetFoldersAsync(string? parentFolder = null); + Task DownloadFileAsync(StoredFile file); + Task> GetFoldersAsync(string? parentFolder = null, string? userId = null); } ``` diff --git a/docs/site/guide/inertia.md b/docs/site/guide/inertia.md index c2d91aa9..c01d1841 100644 --- a/docs/site/guide/inertia.md +++ b/docs/site/guide/inertia.md @@ -11,7 +11,7 @@ SimpleModule uses [Inertia.js](https://inertiajs.com/) to bridge the server-side The Inertia integration in SimpleModule has three layers: 1. **ASP.NET endpoints** call `Inertia.Render()` to specify a component name and props -2. **Blazor SSR** renders the HTML shell with the serialized page data +2. **`IInertiaPageRenderer`** — the built-in `HtmlFileInertiaPageRenderer` reads a static `wwwroot/index.html` shell and substitutes placeholders (page JSON, CSP nonce, deploy version, module CSS links). No Blazor or server-side component rendering is involved. 3. **React ClientApp** hydrates the page by dynamically importing the correct module's page bundle ## Request Flow @@ -30,9 +30,12 @@ InertiaResult.ExecuteAsync() → Serializes page data (component, props, url, version) as JSON → Delegates to IInertiaPageRenderer ↓ -InertiaPageRenderer (Blazor SSR) - → Renders InertiaShell component with page JSON - → Returns full HTML document +HtmlFileInertiaPageRenderer (default IInertiaPageRenderer) + → Loads wwwroot/index.html once at startup and splits it around + the placeholder + → Writes the pre-split HTML, injecting page JSON into + - - + + ``` -The `InertiaPageRenderer` uses Blazor's `HtmlRenderer` to produce static HTML server-side: +At startup the renderer: -```csharp -public sealed class InertiaPageRenderer( - IServiceProvider services, - ILoggerFactory loggerFactory, - IOptions options -) : IInertiaPageRenderer -{ - public async Task RenderPageAsync(HttpContext httpContext, string pageJson) - { - await using var renderer = new HtmlRenderer(services, loggerFactory); - var html = await renderer.Dispatcher.InvokeAsync(async () => - { - var output = await renderer.RenderComponentAsync( - options.Value.ShellComponent, - ParameterView.FromDictionary(new Dictionary - { - ["PageJson"] = pageJson, - ["HttpContext"] = httpContext, - }) - ); - return output.ToHtmlString(); - }); - - httpContext.Response.ContentType = "text/html; charset=utf-8"; - await httpContext.Response.WriteAsync(html); - } -} -``` +1. Reads `index.html` once. +2. Replaces `` with `InertiaMiddleware.Version` for cache-busting. +3. Injects `` tags for each module RCL that ships a `{assembly}.css` asset (replacing ``). +4. Splits the template around `` into `before` / `after` buffers — so every request only concatenates three strings. -You can customize the shell component via `InertiaOptions`: +At request time, `RenderPageAsync` writes: -```csharp -builder.Services.AddSimpleModuleBlazor(options => -{ - options.ShellComponent = typeof(MyCustomShell); -}); +```text +before + + after ``` +and swaps `` with the per-request nonce from `ICspNonce`. In development it also strips the import map and app.js script tag and injects Vite's `/@vite/client` and `/app.tsx` entries when the Vite dev server is active (via `DevToolsConstants.ViteDevServerKey`). + +To swap the renderer, replace the `IInertiaPageRenderer` registration with your own implementation — there is no `InertiaOptions.ShellComponent` or `AddSimpleModuleBlazor` hook. + ## Client Side ### App Bootstrap @@ -280,12 +260,12 @@ For a component name like `"Products/Browse"`: Each module exports a `pages` record in `Pages/index.ts`: ```typescript -// modules/Products/src/Products/Pages/index.ts -export const pages: Record = { - 'Products/Browse': () => import('../Views/Browse'), - 'Products/Manage': () => import('../Views/Manage'), - 'Products/Create': () => import('../Views/Create'), - 'Products/Edit': () => import('../Views/Edit'), +// modules/Products/src/SimpleModule.Products/Pages/index.ts +export const pages: Record = { + 'Products/Browse': () => import('./Browse'), + 'Products/Manage': () => import('./Manage'), + 'Products/Create': () => import('./Create'), + 'Products/Edit': () => import('./Edit'), }; ``` @@ -361,15 +341,15 @@ public class BrowseEndpoint : IViewEndpoint ```typescript // Pages/index.ts -export const pages: Record = { - 'Products/Browse': () => import('../Views/Browse'), +export const pages: Record = { + 'Products/Browse': () => import('./Browse'), }; ``` **3. Page component (React):** ```tsx -// Views/Browse.tsx +// Pages/Browse.tsx export default function Browse({ products }: { products: Product[] }) { return (
@@ -388,7 +368,7 @@ export default function Browse({ products }: { products: Product[] }) { 2. ASP.NET matches the route, calls the endpoint handler 3. `IProductContracts.GetAllProductsAsync()` fetches products from the database 4. `Inertia.Render("Products/Browse", { products })` serializes the page data -5. On initial load: Blazor SSR renders the full HTML shell with embedded JSON +5. On initial load: `HtmlFileInertiaPageRenderer` writes the pre-split `index.html` shell with the JSON injected into `