From ec7772f39f56349ec2137c749f87c91b28b2b569 Mon Sep 17 00:00:00 2001 From: Fadojutimi Temitayo Olusegun Date: Mon, 13 Apr 2026 01:07:31 +0100 Subject: [PATCH 1/6] feat: expand documentation with new core concepts and guides - Added new sections for Macroable, Domain Routing, Signed URLs, and Streaming Responses, enhancing the core concepts documentation. - Introduced Inertia-related guides covering CLI commands, flash messages, forms and validation, and React hooks for improved developer experience. - Updated existing documentation to include references to new topics, ensuring comprehensive coverage of features and best practices. - Enhanced navigation links for easier access to the newly added content. --- astro.config.mjs | 19 ++ .../core-concepts/controllers-and-routing.mdx | 79 +++++ src/content/docs/core-concepts/macroable.mdx | 93 ++++++ src/content/docs/guides/domain-routing.mdx | 91 ++++++ src/content/docs/guides/middleware.mdx | 2 + src/content/docs/guides/signed-urls.mdx | 80 +++++ src/content/docs/guides/streaming.mdx | 96 ++++++ src/content/docs/inertia/cli-commands.mdx | 120 +++++++ src/content/docs/inertia/flash-messages.mdx | 180 +++++++++++ .../docs/inertia/forms-and-validation.mdx | 184 +++++++++++ src/content/docs/inertia/overview.mdx | 141 +++++++++ .../docs/inertia/pages-and-rendering.mdx | 149 +++++++++ src/content/docs/inertia/react-hooks.mdx | 119 +++++++ .../docs/inertia/shared-data-and-props.mdx | 293 ++++++++++++++++++ src/content/docs/inertia/ssr.mdx | 126 ++++++++ src/content/docs/inertia/testing.mdx | 116 +++++++ src/content/docs/inertia/vite-plugin.mdx | 88 ++++++ src/content/docs/integrations/storage.mdx | 95 ++++++ 18 files changed, 2071 insertions(+) create mode 100644 src/content/docs/core-concepts/macroable.mdx create mode 100644 src/content/docs/guides/domain-routing.mdx create mode 100644 src/content/docs/guides/signed-urls.mdx create mode 100644 src/content/docs/guides/streaming.mdx create mode 100644 src/content/docs/inertia/cli-commands.mdx create mode 100644 src/content/docs/inertia/flash-messages.mdx create mode 100644 src/content/docs/inertia/forms-and-validation.mdx create mode 100644 src/content/docs/inertia/overview.mdx create mode 100644 src/content/docs/inertia/pages-and-rendering.mdx create mode 100644 src/content/docs/inertia/react-hooks.mdx create mode 100644 src/content/docs/inertia/shared-data-and-props.mdx create mode 100644 src/content/docs/inertia/ssr.mdx create mode 100644 src/content/docs/inertia/testing.mdx create mode 100644 src/content/docs/inertia/vite-plugin.mdx diff --git a/astro.config.mjs b/astro.config.mjs index 3afb1e8..8f6c14b 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -68,6 +68,7 @@ export default defineConfig({ { label: 'Versioning', slug: 'core-concepts/versioning' }, { label: 'Dependency Injection', slug: 'core-concepts/dependency-injection' }, { label: 'Providers', slug: 'core-concepts/providers' }, + { label: 'Macroable', slug: 'core-concepts/macroable' }, { label: 'Events', slug: 'core-concepts/events' }, { label: 'Lifecycle Hooks', slug: 'core-concepts/lifecycle-hooks' }, { label: 'Configuration', slug: 'core-concepts/configuration' }, @@ -82,6 +83,9 @@ export default defineConfig({ { label: 'Middleware', slug: 'guides/middleware' }, { label: 'Error Handling', slug: 'guides/error-handling' }, { label: 'Environment Typing', slug: 'guides/environment-typing' }, + { label: 'Domain Routing', slug: 'guides/domain-routing' }, + { label: 'Signed URLs', slug: 'guides/signed-urls' }, + { label: 'Streaming Responses', slug: 'guides/streaming' }, ], }, { @@ -125,6 +129,21 @@ export default defineConfig({ { label: 'Auth Guard', slug: 'framework/auth-guard' }, ], }, + { + label: '@stratal/inertia', + items: [ + { label: 'Overview & Setup', slug: 'inertia/overview' }, + { label: 'Pages & Rendering', slug: 'inertia/pages-and-rendering' }, + { label: 'Shared Data & Props', slug: 'inertia/shared-data-and-props' }, + { label: 'Flash Messages', slug: 'inertia/flash-messages' }, + { label: 'SSR', slug: 'inertia/ssr' }, + { label: 'Forms & Validation', slug: 'inertia/forms-and-validation' }, + { label: 'React Hooks', slug: 'inertia/react-hooks' }, + { label: 'Vite Plugin', slug: 'inertia/vite-plugin' }, + { label: 'Testing', slug: 'inertia/testing' }, + { label: 'CLI Commands', slug: 'inertia/cli-commands' }, + ], + }, { label: 'API Reference', attrs: { target: '_blank' }, diff --git a/src/content/docs/core-concepts/controllers-and-routing.mdx b/src/content/docs/core-concepts/controllers-and-routing.mdx index 8aacce3..5fc8e50 100644 --- a/src/content/docs/core-concepts/controllers-and-routing.mdx +++ b/src/content/docs/core-concepts/controllers-and-routing.mdx @@ -39,6 +39,8 @@ The `@Controller` decorator takes two arguments: | `security` | `SecurityScheme[]` | Security schemes applied to every route | | `hideFromDocs` | `boolean` | Exclude all routes from the OpenAPI specification | | `version` | `string \| string[] \| typeof VERSION_NEUTRAL` | API version(s) for this controller (see [Versioning](/core-concepts/versioning/)) | +| `name` | `string` | Name prefix for routes in this controller (used for [URL generation](#named-routes-and-url-generation)) | +| `domain` | `string` | Domain pattern to restrict this controller to (e.g., `'{tenant}.myapp.com'`). See [Domain Routing](/guides/domain-routing/). | ```typescript @Controller('/api/admin', { @@ -357,6 +359,80 @@ The response displays the status, headers, and formatted body. Running `npx quarry api` without a route argument shows the route list, equivalent to `route:list`. +## Route groups and the fluent router API + +Modules can implement the `RouteConfigurable` interface to configure routing with a fluent builder API. This gives you control over route prefixes, domain patterns, middleware, versioning, and grouping — all scoped to the module's controllers. + +```typescript +import { Module, type RouteConfigurable, type Router } from 'stratal/module' +import { AuthMiddleware } from './auth.middleware' + +@Module({ + controllers: [UsersController, AdminController], +}) +export class ApiModule implements RouteConfigurable { + configureRoutes(router: Router) { + router + .name('api.') + .middleware(AuthMiddleware) + .group([UsersController], (r) => { + r.prefix('/users').name('users.') + }) + .group([AdminController], (r) => { + r.prefix('/admin').name('admin.').hideFromDocs() + }) + } +} +``` + +### Fluent methods + +| Method | Description | +| --- | --- | +| `prefix(path, params?)` | Set a path prefix for all routes. Optionally pass a Zod schema to validate path parameters. | +| `domain(pattern)` | Restrict routes to a domain pattern (e.g., `'{tenant}.myapp.com'`). See [Domain Routing](/guides/domain-routing/). | +| `name(prefix)` | Add a name prefix to all routes (e.g., `'api.'` makes `index` become `'api.index'`). | +| `middleware(...classes)` | Apply middleware to all routes in scope. | +| `version(v)` | Set the API version (string or string array). | +| `hideFromDocs(hide?)` | Hide routes from the OpenAPI spec. | +| `use(...classes)` | Register global middleware (root router only). | +| `group(controllers, callback)` | Create a sub-group with its own prefix, name, middleware, etc. | + +### Named routes and URL generation + +When routes have names (from `router.name()` or `@Route({ name: '...' })`), you can generate URLs using `ctx.route()`: + +```typescript +@Controller('/users') +export class UsersController implements IController { + @Route({ name: 'users.show' }) + show(ctx: RouterContext) { + const id = ctx.param('id') + return ctx.json({ id }) + } + + @Route() + index(ctx: RouterContext) { + // Generate a URL to the show route + const url = ctx.route('users.show', { id: '42' }) + // → '/users/42' + return ctx.json({ users: [], links: { detail: url } }) + } +} +``` + +For type-safe route names, generate types with the CLI: + +```bash +npx quarry route:types +``` + +This creates a `src/stratal.d.ts` file that provides autocomplete for route names and validates parameters. + + + ## Next steps - [HTTP Method Decorators](/core-concepts/http-method-decorators/) for explicit `@Get`, `@Post`, etc. when you need custom paths or non-CRUD endpoints. @@ -364,4 +440,7 @@ The response displays the status, headers, and formatted body. - [Modules](/core-concepts/modules/) to learn how controllers are registered in modules. - [Dependency Injection](/core-concepts/dependency-injection/) to inject services into your controllers. - [Guards guide](/guides/guards/) for writing custom guards. +- [Domain Routing](/guides/domain-routing/) for routing by subdomain patterns. +- [Signed URLs](/guides/signed-urls/) for generating tamper-proof links. +- [Streaming Responses](/guides/streaming/) for streaming data to clients. - [OpenAPI overview](/openapi/overview/) to see how `@Route` schemas generate API documentation. diff --git a/src/content/docs/core-concepts/macroable.mdx b/src/content/docs/core-concepts/macroable.mdx new file mode 100644 index 0000000..a6bc5a4 --- /dev/null +++ b/src/content/docs/core-concepts/macroable.mdx @@ -0,0 +1,93 @@ +--- +title: Macroable +description: Extend classes at runtime by registering custom methods, properties, and getters using the Macroable pattern. +--- + +import { Aside } from '@astrojs/starlight/components' + +The Macroable pattern lets you add methods, properties, and getters to a class at runtime without modifying the class itself. This is the primary mechanism packages use to extend framework classes -- for example, `@stratal/inertia` adds `ctx.inertia()` and `ctx.flash()` to `RouterContext`. + +```typescript +import { Macroable } from 'stratal/macroable' +``` + +## Registering macros + +Use `Class.macro()` to attach a new method to the class prototype. The method is available on every instance. + +```typescript +import { RouterContext } from 'stratal/router' + +RouterContext.macro('greet', function (this: RouterContext) { + return this.json({ message: 'Hello!' }) +}) + +// Now available on all RouterContext instances: +// ctx.greet() +``` + +## Instance properties + +Use `Class.instanceProperty()` to define a property whose value is computed per instance. The factory function runs once for each new instance. + +```typescript +RouterContext.instanceProperty('requestId', function (this: RouterContext) { + return crypto.randomUUID() +}) +``` + +Each `RouterContext` instance will have its own unique `requestId`. + +## Getters + +Use `Class.getter()` to define a computed getter. Pass `true` as the third argument to cache the result after the first access (singleton mode). + +```typescript +RouterContext.getter('userAgent', function (this: RouterContext) { + return this.header('User-Agent') ?? 'unknown' +}, true) // true = singleton (computed once per instance) +``` + +When singleton mode is off (the default), the getter function runs on every property access. + +## Introspection + +Check whether a macro, instance property, or getter has been registered: + +```typescript +RouterContext.hasMacro('greet') // true +``` + +Remove all registered macros, instance properties, and getters. This is useful in test teardown to reset state between tests. + +```typescript +RouterContext.flushMacros() +``` + +## API reference + +| Method | Description | +|---|---| +| `Class.macro(name, fn)` | Add a prototype method. | +| `Class.instanceProperty(name, fn)` | Add a per-instance bound property. | +| `Class.getter(name, fn, singleton?)` | Add a computed getter, optionally cached after first access. | +| `Class.hasMacro(name)` | Check if a macro, property, or getter is registered. | +| `Class.flushMacros()` | Remove all registered macros, properties, and getters. | + +## Type safety + +TypeScript will not know about macros unless you declare them. Use module augmentation to add type information for your custom macros. + +```typescript +declare module 'stratal/router' { + interface RouterContext { + greet(): Response + requestId: string + readonly userAgent: string + } +} +``` + + diff --git a/src/content/docs/guides/domain-routing.mdx b/src/content/docs/guides/domain-routing.mdx new file mode 100644 index 0000000..d2fffe3 --- /dev/null +++ b/src/content/docs/guides/domain-routing.mdx @@ -0,0 +1,91 @@ +--- +title: Domain Routing +description: Route requests based on domain patterns to support multi-tenancy, regional subdomains, and custom domain logic. +--- + +import { Aside } from '@astrojs/starlight/components' + +Domain routing lets you match routes based on the request's hostname rather than just the URL path. You can extract parameters from subdomain patterns, making it straightforward to build multi-tenant applications, regional endpoints, and custom domain logic. + +## Controller-level domain + +Apply a domain constraint directly on a controller using the `domain` option in the `@Controller()` decorator. All routes within the controller will only match when the request hostname fits the pattern. + +```typescript +import { Controller, Route, type RouterContext } from 'stratal/router' + +@Controller('/dashboard', { domain: '{tenant}.myapp.com' }) +class DashboardController { + @Route() + index(ctx: RouterContext) { + const tenant = ctx.domain('tenant') + return ctx.json({ tenant, message: `Welcome to ${tenant}'s dashboard` }) + } +} +``` + +Parameters wrapped in curly braces (e.g., `{tenant}`) are extracted from the hostname and made available through `ctx.domain()`. + +## Router-level domain + +For broader control, set the domain constraint at the module level by implementing the `RouteConfigurable` interface. Every controller registered in the module will inherit the domain rule. + +```typescript +import { Module, type RouteConfigurable, type Router } from 'stratal/module' + +@Module({ + controllers: [DashboardController], +}) +class TenantModule implements RouteConfigurable { + configureRoutes(router: Router) { + router.domain('{tenant}.myapp.com') + } +} +``` + +## Accessing domain parameters + +Use `ctx.domain()` inside any route handler to retrieve a captured hostname segment by name. + +```typescript +const tenant = ctx.domain('tenant') +``` + +### Multiple parameters + +Domain patterns can contain more than one parameter. Each segment between dots is matched independently. + +```typescript +// Pattern: '{region}.{tenant}.example.com' +// Request: us-east.acme.example.com + +const region = ctx.domain('region') // 'us-east' +const tenant = ctx.domain('tenant') // 'acme' +``` + +## Grouping routes with domain and prefix + +Use `router.group()` inside `configureRoutes` to scope a set of controllers under a shared domain, prefix, and middleware. + +```typescript +import { Module, type RouteConfigurable, type Router } from 'stratal/module' + +@Module({ + controllers: [DashboardController, SettingsController], +}) +class TenantModule implements RouteConfigurable { + configureRoutes(router: Router) { + router + .domain('{tenant}.myapp.com') + .group([DashboardController, SettingsController], (r) => { + r.prefix('/api').middleware(TenantMiddleware) + }) + } +} +``` + +In this example, both `DashboardController` and `SettingsController` are reachable only when the request matches `{tenant}.myapp.com`, all paths are prefixed with `/api`, and `TenantMiddleware` runs before every handler. + + diff --git a/src/content/docs/guides/middleware.mdx b/src/content/docs/guides/middleware.mdx index 7a64a02..7f1acf6 100644 --- a/src/content/docs/guides/middleware.mdx +++ b/src/content/docs/guides/middleware.mdx @@ -283,5 +283,7 @@ Services resolved later in the request (guards, controllers, other middleware) c ## Next steps - [Guards](/guides/guards/) to learn about access control that runs after middleware. +- [Domain Routing](/guides/domain-routing/) for routing requests based on subdomain patterns. +- [Signed URLs](/guides/signed-urls/) for generating tamper-proof, expiring links. - [Modules](/core-concepts/modules/) for how modules organize middleware and other providers. - [Dependency Injection](/core-concepts/dependency-injection/) for more on the request-scoped container. diff --git a/src/content/docs/guides/signed-urls.mdx b/src/content/docs/guides/signed-urls.mdx new file mode 100644 index 0000000..37fe3ae --- /dev/null +++ b/src/content/docs/guides/signed-urls.mdx @@ -0,0 +1,80 @@ +--- +title: Signed URLs +description: Generate and verify tamper-proof signed URLs for secure, expiring links to your application routes. +--- + +import { Aside } from '@astrojs/starlight/components' + +Signed URLs attach an HMAC-SHA256 signature (and an optional expiration timestamp) as query parameters to a URL. This lets you share links that cannot be tampered with -- if anyone modifies the path, query string, or expiration, the signature check fails. + +Common use cases include temporary download links, email verification, password reset flows, and shareable preview links. + +## Prerequisites + +Set an `APP_SECRET` environment variable in your Worker. This value is used as the HMAC signing key. + +```toml +# wrangler.toml +[vars] +APP_SECRET = "your-secret-key" +``` + +## Generating signed URLs + +Call `ctx.signedUrl()` from any route handler to produce a signed link to a named route. The method accepts the route name, route parameters, and an options object. + +```typescript +import { Controller, Route, type RouterContext } from 'stratal/router' + +@Controller('/files') +class FilesController { + @Route({ name: 'files.download' }) + async show(ctx: RouterContext) { + const url = await ctx.signedUrl('files.download', { id: '123' }, { + expiresIn: 3600, // 1 hour + }) + return ctx.json({ downloadUrl: url }) + } +} +``` + +### Options + +| Option | Type | Description | +|---|---|---| +| `expiresIn` | `number` | Time in seconds until the signed URL expires. Omit for a non-expiring link. | + +## Verifying signatures + +Use `ctx.hasValidSignature()` to check whether the current request URL carries a valid, non-expired signature. + +```typescript +@Route({ name: 'files.download' }) +async show(ctx: RouterContext) { + const valid = await ctx.hasValidSignature() + if (!valid) { + return ctx.json({ error: 'Invalid or expired link' }, 403) + } + // serve the file... +} +``` + +The method returns `false` if the signature is missing, does not match, or the link has expired. + +## Standalone functions + +If you need to sign or verify URLs outside of a route handler -- for example in a queue consumer or scheduled task -- use the standalone helpers. + +```typescript +import { signUrl, verifySignedUrl } from 'stratal/router' + +const signed = await signUrl('https://example.com/download/123', secret, { + expiresIn: 3600, +}) + +const isValid = await verifySignedUrl(signed, secret) +``` + + diff --git a/src/content/docs/guides/streaming.mdx b/src/content/docs/guides/streaming.mdx new file mode 100644 index 0000000..13e606b --- /dev/null +++ b/src/content/docs/guides/streaming.mdx @@ -0,0 +1,96 @@ +--- +title: Streaming Responses +description: Stream binary data, text, and Server-Sent Events from your controllers using the built-in streaming API. +--- + +import { Aside } from '@astrojs/starlight/components' + +Stratal provides three streaming methods on `RouterContext`, each tailored to a different content type. All three follow the same callback pattern and support optional error handling. + +## Binary streaming + +Use `ctx.stream()` to send raw binary or encoded data. You are responsible for encoding the content before writing. + +```typescript +import { Controller, Route, type RouterContext } from 'stratal/router' + +@Controller('/data') +class DataController { + @Route() + show(ctx: RouterContext) { + return ctx.stream(async (stream) => { + await stream.write(new TextEncoder().encode('chunk 1')) + await stream.write(new TextEncoder().encode('chunk 2')) + await stream.close() + }) + } +} +``` + +## Text streaming + +Use `ctx.streamText()` for plain text responses. This method automatically sets the `Content-Encoding: Identity` header, which is required for streaming to work on Cloudflare Workers. + +```typescript +@Route() +show(ctx: RouterContext) { + return ctx.streamText(async (stream) => { + await stream.write('Hello ') + await stream.write('World') + await stream.close() + }) +} +``` + +This is particularly useful for streaming AI model responses token by token. + +## Server-Sent Events + +Use `ctx.streamSSE()` to send an SSE stream. The correct `Content-Type` and `Content-Encoding` headers are set automatically. + +```typescript +@Route() +index(ctx: RouterContext) { + return ctx.streamSSE(async (stream) => { + for (let i = 0; i < 5; i++) { + await stream.writeSSE({ + event: 'message', + data: JSON.stringify({ count: i }), + id: String(i), + }) + await stream.sleep(1000) + } + }) +} +``` + +The `writeSSE()` method accepts an object with the following fields: + +| Field | Type | Description | +|---|---|---| +| `event` | `string` | The event name the client listens for. | +| `data` | `string` | The event payload. Serialize objects with `JSON.stringify()`. | +| `id` | `string` | Optional event ID for client reconnection tracking. | + +The `stream.sleep()` helper pauses for the given number of milliseconds, which is handy for throttling or simulating intervals. + +## Error handling + +All three streaming methods accept an optional second argument -- an error callback that is invoked if the stream callback throws. + +```typescript +ctx.streamSSE( + async (stream) => { + // stream logic... + }, + async (err) => { + console.error('Stream error:', err) + } +) +``` + +Without an error handler, exceptions inside the stream callback will be silently swallowed after the response headers have already been sent. + + diff --git a/src/content/docs/inertia/cli-commands.mdx b/src/content/docs/inertia/cli-commands.mdx new file mode 100644 index 0000000..8488c69 --- /dev/null +++ b/src/content/docs/inertia/cli-commands.mdx @@ -0,0 +1,120 @@ +--- +title: CLI Commands +description: Scaffold, develop, build, and generate types for your Inertia application using Quarry CLI commands. +--- + +import { Aside } from '@astrojs/starlight/components'; + +The Quarry CLI provides commands for scaffolding, developing, building, and generating types for your Inertia application. All commands are registered automatically when `InertiaModule` is imported in your application module. + +## Commands overview + +| Command | Description | +| --- | --- | +| `inertia:install` | Scaffold the Inertia directory structure and starter files | +| `inertia:dev` | Start the Vite development server with HMR | +| `inertia:build` | Build client and SSR bundles for production | +| `inertia:types` | Generate TypeScript types for page components | + +## inertia:install + +Scaffolds the Inertia directory structure and creates starter files so you can begin building immediately. + +```bash +npx quarry inertia:install +``` + +This creates the following files: + +| File | Purpose | +| --- | --- | +| `src/inertia/app.tsx` | Client-side entry point | +| `src/inertia/ssr.tsx` | Server-side rendering entry point | +| `src/inertia/root.html` | Root HTML template | +| `src/inertia/pages/Home.tsx` | Sample page component | + +### Flags + +| Flag | Description | +| --- | --- | +| `--skip-deps` | Skip installing npm dependencies. Use this if you want to manage dependencies yourself. | + +## inertia:dev + +Starts the Vite development server with hot module replacement enabled. This is the recommended way to develop Inertia applications. + +```bash +npx quarry inertia:dev +``` + + + +### Flags + +| Flag | Default | Description | +| --- | --- | --- | +| `--port` | `5173` | Port for the Vite development server | +| `--host` | — | Expose the server to your local network | + +### Example + +```bash +npx quarry inertia:dev --port=3000 --host +``` + +## inertia:build + +Builds the client bundle and optionally the SSR bundle for production deployment. + +```bash +npx quarry inertia:build +``` + +### Flags + +| Flag | Default | Description | +| --- | --- | --- | +| `--outDir` | `dist` | Output directory for the build | +| `--ssr` | — | Also build the SSR bundle | + +### Example + +Build both client and SSR bundles: + +```bash +npx quarry inertia:build --ssr +``` + +Build to a custom output directory: + +```bash +npx quarry inertia:build --outDir=build +``` + +## inertia:types + +Generates TypeScript type definitions for your page components. It scans `src/inertia/pages/` for React components and produces types for the `InertiaPageRegistry`, enabling type-safe rendering from your controllers. + +```bash +npx quarry inertia:types +``` + +### Flags + +| Flag | Description | +| --- | --- | +| `--watch` | Regenerate types automatically when page files change | + +### Example + +Run in watch mode during development: + +```bash +npx quarry inertia:types --watch +``` + + diff --git a/src/content/docs/inertia/flash-messages.mdx b/src/content/docs/inertia/flash-messages.mdx new file mode 100644 index 0000000..c514006 --- /dev/null +++ b/src/content/docs/inertia/flash-messages.mdx @@ -0,0 +1,180 @@ +--- +title: Flash Messages +description: Store short-lived data between requests using flash messages with the built-in CookieFlashStore or a custom store. +--- + +import { Aside } from '@astrojs/starlight/components'; + +Flash messages are short-lived pieces of data that persist for exactly one subsequent request and are then discarded. They are typically used to display success or error messages after form submissions, redirects, or other state-changing operations. For example, after creating a new record you might flash a success message, redirect to an index page, and show that message once. + +## Configuration + +Pass a flash store instance to `InertiaModule.forRoot()` to enable flash messages: + +```typescript +import { Module } from 'stratal/module' +import { InertiaModule, CookieFlashStore } from '@stratal/inertia' +import rootView from './inertia/root.html' + +@Module({ + imports: [ + InertiaModule.forRoot({ + rootView, + flash: { + store: new CookieFlashStore({ + secret: env.APP_SECRET, + }), + }, + }), + ], +}) +export class AppModule {} +``` + +## Built-in CookieFlashStore + +`CookieFlashStore` stores flash data in an HMAC-signed cookie. It works out of the box with no external dependencies. + +```typescript +import { CookieFlashStore } from '@stratal/inertia' + +new CookieFlashStore({ + secret: env.APP_SECRET, // HMAC signing secret + cookie: 'stratal_flash', // cookie name (default) + cookieOptions: { // optional + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, +}) +``` + +| Option | Type | Default | Description | +| --------------- | -------- | ------------------ | ------------------------------------ | +| `secret` | `string` | **required** | Secret used to sign the cookie value | +| `cookie` | `string` | `'stratal_flash'` | Name of the cookie | +| `cookieOptions` | `object` | `{}` | Standard cookie attributes | + + + +## Setting flash data + +Use `ctx.flash()` inside any controller handler to store data for the next request: + +```typescript +import { Controller, IController, RouterContext } from 'stratal/router' +import { InertiaPost } from '@stratal/inertia' + +@Controller('/posts') +export class PostsController implements IController { + @InertiaPost('/') + async store(ctx: RouterContext) { + // ... create the post + ctx.flash('success', 'Post created successfully!') + return ctx.redirect('/posts') + } +} +``` + +You can flash any serializable value: + +```typescript +ctx.flash('success', 'Item created!') +ctx.flash('error', 'Something went wrong.') +ctx.flash('formData', { title: 'Draft', body: '' }) +``` + +## Reading flash data on the frontend + +Flash data is automatically merged into the page props on the next request. Access it through Inertia's `usePage()` hook: + +```tsx +import { usePage } from '@inertiajs/react' + +export default function PostsIndex({ posts }) { + const { flash } = usePage().props + + return ( +
+ {flash.success && ( +
{flash.success}
+ )} + {flash.error && ( +
{flash.error}
+ )} + {/* render posts */} +
+ ) +} +``` + + + +## Custom flash stores + +If cookies are not suitable for your use case (for example, when flash payloads are large or you need server-side storage), implement the `FlashStore` interface: + +```typescript +interface FlashStore { + read(ctx: RouterContext): Promise> + write(ctx: RouterContext, data: Record): Promise + clear(ctx: RouterContext): Promise +} +``` + +| Method | Purpose | +| ------- | ---------------------------------------------------- | +| `read` | Retrieve the current flash data for this request | +| `write` | Persist flash data so it is available on the next request | +| `clear` | Remove flash data after it has been read | + +### Example: KV-based flash store + +The following example stores flash data in a Cloudflare KV namespace, keyed by a session identifier: + +```typescript +import type { FlashStore } from '@stratal/inertia' +import type { RouterContext } from 'stratal/router' + +export class KvFlashStore implements FlashStore { + constructor(private readonly binding: KVNamespace) {} + + async read(ctx: RouterContext): Promise> { + const key = this.getKey(ctx) + const data = await this.binding.get(key, 'json') + return (data as Record) ?? {} + } + + async write(ctx: RouterContext, data: Record): Promise { + const key = this.getKey(ctx) + await this.binding.put(key, JSON.stringify(data), { + expirationTtl: 300, // 5 minutes + }) + } + + async clear(ctx: RouterContext): Promise { + const key = this.getKey(ctx) + await this.binding.delete(key) + } + + private getKey(ctx: RouterContext): string { + const sessionId = ctx.c.req.cookie('session_id') + return `flash:${sessionId}` + } +} +``` + +Register it the same way as the built-in store: + +```typescript +InertiaModule.forRoot({ + rootView, + flash: { + store: new KvFlashStore(env.FLASH_KV), + }, +}) +``` diff --git a/src/content/docs/inertia/forms-and-validation.mdx b/src/content/docs/inertia/forms-and-validation.mdx new file mode 100644 index 0000000..4d36bfd --- /dev/null +++ b/src/content/docs/inertia/forms-and-validation.mdx @@ -0,0 +1,184 @@ +--- +title: "Forms & Validation" +description: "Handle form submissions, display validation errors, and use precognition for real-time form validation." +--- + +import { Aside } from '@astrojs/starlight/components'; + +Stratal integrates tightly with Inertia's form handling to give you automatic redirect handling, validation error flashing, and optional real-time validation via precognition. This page covers how to validate form submissions on the server, surface errors on the frontend, and enable live validation without a full form submit. + +## How form handling works + +Inertia converts certain redirects automatically to prevent browser form resubmission. When a `POST`, `PUT`, `PATCH`, or `DELETE` request returns a `302` redirect, Stratal converts it to a `303` redirect. This forces the browser to follow the redirect with a `GET` request, preventing the "confirm form resubmission" dialog that users would otherwise see when navigating back. + +## Validating form data with Zod + +Define a Zod schema and pass it to the route decorator. Stratal validates the request body before your handler runs: + +```typescript +import { Controller, IController, RouterContext } from 'stratal/router' +import { InertiaPost } from '@stratal/inertia' +import { z } from 'stratal/validation' + +const createPostSchema = z.object({ + title: z.string().min(1), + body: z.string().min(10), +}) + +@Controller('/posts') +export class PostsController implements IController { + @InertiaPost('/', { body: createPostSchema }) + async store(ctx: RouterContext) { + const data = await ctx.body>() + const post = await this.postService.create(data) + + ctx.flash('success', 'Post created!') + return ctx.redirect(`/posts/${post.id}`) + } +} +``` + +When the body passes validation, `ctx.body()` returns the parsed data and the handler executes normally. + +## Automatic error handling + +When validation fails, Stratal handles the error response automatically: + +### Schema validation errors + +If the request body does not match the Zod schema, a `SchemaValidationError` is thrown. Stratal catches it and: + +1. Redirects back to the previous page. +2. Flashes validation errors as `{ field: 'message' }` under the `errors` key. + +For example, if the `title` field is empty, the flashed errors would look like: + +```json +{ + "errors": { + "title": "String must contain at least 1 character(s)" + } +} +``` + +### Application errors + +When an `ApplicationError` is thrown (for example, a duplicate record or a business rule violation), Stratal flashes it under the `_form` key: + +```json +{ + "errors": { + "_form": "A post with this title already exists." + } +} +``` + +This lets you distinguish between field-level and form-level errors on the frontend. + +## Displaying errors on the frontend + +Validation errors are available through Inertia's `usePage()` hook: + +```tsx +import { usePage } from '@inertiajs/react' + +export default function CreatePost() { + const { errors } = usePage().props + + return ( +
+
+ + + {errors.title &&

{errors.title}

} +
+ +
+ +