Frank.Datastar 7.3.2

There is a newer version of this package available.
See the version list below for details.
dotnet add package Frank.Datastar --version 7.3.2
                    
NuGet\Install-Package Frank.Datastar -Version 7.3.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Frank.Datastar" Version="7.3.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Frank.Datastar" Version="7.3.2" />
                    
Directory.Packages.props
<PackageReference Include="Frank.Datastar" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Frank.Datastar --version 7.3.2
                    
#r "nuget: Frank.Datastar, 7.3.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Frank.Datastar@7.3.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Frank.Datastar&version=7.3.2
                    
Install as a Cake Addin
#tool nuget:?package=Frank.Datastar&version=7.3.2
                    
Install as a Cake Tool

Frank.Datastar

An F# library that integrates Datastar's Server-Sent Events (SSE) capabilities with the Frank web framework through idiomatic computation expression builders.

Features

  • Single Stream Handler: Provides one datastar custom operation for SSE streaming
  • Helper Functions: Clean Datastar.* helper functions for common operations
  • Stream-Based Rendering: Zero-allocation HTML streaming via TextWriter for high-throughput scenarios
  • Type-Safe: Leverages F# type system for safe signal handling
  • Hypermedia-First: Designed around sending HTML (not managing client state)
  • HTTP Method Flexibility: Supports GET, POST, and other HTTP methods

Installation

dotnet add package Frank.Datastar

Quick Start

Basic Streaming Example

open Frank
open Frank.Builder
open Frank.Datastar

let displayTime =
    resource "/time" {
        name "DisplayTime"
        datastar (fun ctx -> task {
            let time = System.DateTime.Now.ToString("HH:mm:ss")
            do! Datastar.patchElements $"""<div id="time">{time}</div>""" ctx
        })
    }

Multiple Progressive Updates

The primary use case for Datastar is streaming multiple HTML updates:

let loadDashboard =
    resource "/dashboard" {
        name "LoadDashboard"
        datastar (fun ctx -> task {
            // Send header first
            do! Datastar.patchElements """<div id="header">Loading...</div>""" ctx

            // Fetch and send stats
            let! stats = fetchStatsAsync()
            do! Datastar.patchElements $"""<div id="stats">{renderStats stats}</div>""" ctx

            // Fetch and send activity
            let! activity = fetchActivityAsync()
            do! Datastar.patchElements $"""<div id="activity">{renderActivity activity}</div>""" ctx
        })
    }

POST with Signal Reading

[<CLIMutable>]
type FormSignals = { query: string }

let submitSearch =
    resource "/search" {
        name "SubmitSearch"
        datastar HttpMethods.Post (fun ctx -> task {
            let! signals = Datastar.tryReadSignals<FormSignals> ctx

            match signals with
            | ValueSome form ->
                let! results = searchAsync form.query
                do! Datastar.patchElements (renderResults results) ctx
            | ValueNone ->
                do! Datastar.patchElements """<div id="error">Invalid form</div>""" ctx
        })
    }

Stream-Based Rendering (High Performance)

For high-throughput scenarios or large HTML payloads, use stream-based overloads to eliminate string allocations:

// With manual TextWriter usage
let loadDashboardStreaming =
    resource "/dashboard-stream" {
        name "LoadDashboardStreaming"
        datastar (fun ctx -> task {
            do! Datastar.streamPatchElements (fun writer -> task {
                do! writer.WriteAsync("<div id='stats'>")
                do! writer.WriteAsync("Users: 1,234")
                do! writer.WriteAsync("</div>")
            }) ctx
        })
    }

// With view engine supporting TextWriter (e.g., Hox)
open Hox.Rendering

let loadUsersStreaming =
    resource "/users-stream" {
        name "LoadUsersStreaming"
        datastar (fun ctx -> task {
            let! users = fetchUsersAsync()
            let node = h("div#user-list", fragment [ for user in users do userCard user ])

            // Stream directly to response - no intermediate string allocation
            do! Datastar.streamPatchElements (fun writer ->
                Render.toTextWriter writer node
            ) ctx
        })
    }

API Reference

Datastar Philosophy

Hypermedia First: The primary pattern in Datastar is sending HTML from the server. The server is the source of truth.

Minimal Signals: Signals should be used sparingly, primarily for:

  • Form input bindings (<input data-bind:field>)
  • Ephemeral UI state (toggle switches, tabs)
  • Passing small amounts of data to the server

The datastar Custom Operation

The only custom operation on ResourceBuilder. Starts an SSE stream and executes your handler:

// Default: GET method
resource "/endpoint" {
    datastar (fun ctx -> task {
        do! Datastar.patchElements "<div>Content</div>" ctx
    })
}

// With specific HTTP method
resource "/endpoint" {
    datastar HttpMethods.Post (fun ctx -> task {
        let! signals = Datastar.tryReadSignals<MySignals> ctx
        // Process and respond...
    })
}

Helper Functions

The Datastar module provides helper functions for use inside the datastar handler:

module Datastar =
    // PRIMARY: Send HTML to update the UI
    let patchElements (html: string) (ctx: HttpContext) : Task<unit>

    // STREAM-BASED: Zero-allocation HTML streaming
    let streamPatchElements (writer: TextWriter -> Task) (ctx: HttpContext) : Task<unit>

    // SECONDARY: Update ephemeral client state
    let patchSignals (signals: string) (ctx: HttpContext) : Task<unit>
    let streamPatchSignals (writer: TextWriter -> Task) (ctx: HttpContext) : Task<unit>

    // Remove an element by CSS selector
    let removeElement (selector: string) (ctx: HttpContext) : Task<unit>
    let streamRemoveElement (writer: TextWriter -> Task) (ctx: HttpContext) : Task<unit>

    // Execute JavaScript (use sparingly)
    let executeScript (script: string) (ctx: HttpContext) : Task<unit>
    let streamExecuteScript (writer: TextWriter -> Task) (ctx: HttpContext) : Task<unit>

    // Read and deserialize signals from request body
    let tryReadSignals<'T> (ctx: HttpContext) : Task<voption<'T>>
Stream-Based vs String-Based

Use stream-based overloads when:

  • High throughput required (1000+ events/sec)
  • Large HTML payloads (reducing allocations matters)
  • View engine supports TextWriter output (e.g., Hox Render.toTextWriter)

Use string-based API when:

  • Simple scenarios with small HTML strings
  • View engine only produces strings
  • Allocation profile is not a concern

Stream-based operations eliminate full HTML string materialization, providing 50%+ allocation reduction in high-throughput scenarios.

Usage Priority

  1. Primary: Datastar.patchElements - Send HTML to update the UI
  2. Supporting: Datastar.tryReadSignals - Read form inputs to decide what HTML to send
  3. Rare: Datastar.patchSignals - Update minimal client state (counters, flags)
  4. Special: Datastar.executeScript, Datastar.removeElement - For specific use cases

Complete Example

open System
open Frank
open Frank.Builder
open Frank.Datastar
open Microsoft.AspNetCore.Builder

[<CLIMutable>]
type SearchSignals = { query: string }

let displayDate =
    resource "/displayDate" {
        name "DisplayDate"
        datastar (fun ctx -> task {
            let today = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
            do! Datastar.patchElements $"""<div id='target'><b>{today}</b></div>""" ctx
        })
    }

let searchItems =
    resource "/search" {
        name "SearchItems"
        datastar (fun ctx -> task {
            let query = ctx.Request.Query.["q"].ToString()

            let results =
                [ "Apple"; "Banana"; "Cherry"; "Date" ]
                |> List.filter (fun item -> item.Contains(query, StringComparison.OrdinalIgnoreCase))

            let html =
                if results.IsEmpty then
                    """<div id='results'>No results</div>"""
                else
                    let items = results |> List.map (fun r -> $"<li>{r}</li>") |> String.concat ""
                    $"""<ul id='results'>{items}</ul>"""

            do! Datastar.patchElements html ctx
        })
    }

let loadDashboard =
    resource "/dashboard" {
        name "LoadDashboard"
        datastar (fun ctx -> task {
            // Progressive loading: send updates as they become available
            do! Datastar.patchElements """<div id='header'>Dashboard</div>""" ctx
            do! Task.Delay(100)
            do! Datastar.patchElements """<div id='stats'>Users: 1,234</div>""" ctx
            do! Task.Delay(100)
            do! Datastar.patchElements """<div id='activity'>3 new items</div>""" ctx
        })
    }

[<EntryPoint>]
let main args =
    webHost args {
        useDefaults
        plug StaticFileExtensions.UseStaticFiles

        resource displayDate
        resource searchItems
        resource loadDashboard
    }
    0

Architecture

Frank.Datastar is built on:

  1. Frank: Provides the computation expression framework for defining HTTP resources
  2. A native SSE implementation: Writes Datastar's SSE event grammar directly via ASP.NET Core's IBufferWriter<byte> API — no external Datastar SDK dependency
  3. ASP.NET Core: The underlying web framework

The library extends Frank's ResourceBuilder with the datastar custom operation that:

  • Starts the SSE stream automatically
  • Executes your handler function
  • Manages response headers and flushing

Hox Integration

Frank.Datastar works with Hox for type-safe HTML rendering:

open Hox
open Hox.Core
open Hox.Rendering

let userCard (user: User) =
    h("div.user-card",
        [ h($"img [src={user.Avatar}] [alt={user.Name}]", [])
          h("div.user-info",
              [ h("h3", [ Text user.Name ])
                h("p", [ Text user.Email ]) ]) ])

// String-based rendering
let loadUsers =
    resource "/users" {
        name "LoadUsers"
        datastar (fun ctx -> task {
            let! users = fetchUsersAsync()
            let node = h("div#user-list", fragment [ for user in users do userCard user ])
            let! html = Render.asString node
            do! Datastar.patchElements html ctx
        })
    }

// Stream-based rendering (zero allocations)
let loadUsersStreaming =
    resource "/users-streaming" {
        name "LoadUsersStreaming"
        datastar (fun ctx -> task {
            let! users = fetchUsersAsync()
            let node = h("div#user-list", fragment [ for user in users do userCard user ])
            // Stream directly to response - no string allocation
            do! Datastar.streamPatchElements (fun writer ->
                Render.toTextWriter writer node
            ) ctx
        })
    }

Note: Hox uses CSS selector notation for attributes: [attr=value]

Performance Tip: Use Render.toTextWriter with streamPatchElements for zero-allocation HTML streaming in high-throughput scenarios.

  • Frank - F# web framework
  • Datastar - Hypermedia framework
  • Hox - Async HTML rendering library for F#

Sample applications: Frank.Datastar.Basic, Frank.Datastar.Hox, and Frank.Datastar.Oxpecker.

See the project repository for the complete guide.

License

MIT

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
7.3.3 83 8/11/2026
7.3.2 85 8/10/2026
7.2.1 118 6/22/2026
7.2.0 149 2/10/2026
7.1.0 145 2/7/2026
7.0.0-build.0 80 2/6/2026
6.5.0 165 2/5/2026
6.4.1 142 2/4/2026
6.4.1-build.0 79 2/4/2026
6.4.0 136 2/2/2026
6.4.0-build.0 80 2/2/2026

### New in 7.3.2 (Released 2026-08-09)
**Frank Core - HandlerDefinition Metadata Refactor**
- **`HandlerDefinition` is now an open metadata list:** `{ Handler: RequestDelegate; Metadata: obj list }`, replacing the six fixed fields (`Name`, `Summary`, `Description`, `Tags`, `Produces`, `Accepts`). The `handler { }` computation expression's operations (`handle`, `name`, `summary`, `description`, `tags`, `produces`, `producesEmpty`, `accepts`) are unchanged — this is an internal representation change, not an authoring change.
- **`ProducesInfo` and `AcceptsInfo` removed** — these staging record types are no longer needed since metadata is built directly. Use `HandlerDefinition.tryFind<'T>`/`findAll<'T>` to inspect metadata (e.g. `HandlerDefinition.findAll<IProducesResponseTypeMetadata>`) if you referenced these types directly rather than through the `handler { }` CE.
- **Breaking namespace move:** the `get`/`post`/`put`/`delete`/`patch`/`head`/`options` overloads that accept a `HandlerDefinition` moved from `Frank.OpenApi` into Frank core (`Frank.Builder`). If your project only had `open Frank.OpenApi` and not `open Frank.Builder`, add `open Frank.Builder` — the `resource { }` CE already requires it, so most consumers are unaffected.
- **New: `ResourceBuilder.AddMethodMetadata`** — scopes an endpoint-metadata convention to a single HTTP method within a resource, promoted from a private `Frank.OpenApi` helper to a public core operation.
**Frank Core - Shared Response Link Provider**
- **New: `link` operation, on both `webHost { }` and `resource { }`** — any code can now contribute an RFC 8288 `Link` response header entry through one shared mechanism (`WebLink`, `WebHostSpec.LinkProviders`), replacing the ad-hoc, independently-duplicated `Link`-header middleware `Frank.JsonHome` and `Frank.OpenApi` previously each rolled themselves. Two overloads either way: `link "https://example.com/license" "license"` for a static target/relation, or `link (fun ctx -> ...)` for a value computed per request. Contributions from multiple sources combine into one multi-value `Link` header rather than one overwriting another.
- **App-wide (`webHost { link ... }`):** applies to every response, including unmatched routes (404s) and responses regenerated by `UseExceptionHandler` — the same guarantee `Frank.JsonHome`'s and `Frank.OpenApi`'s existing Link headers already had, now backed by one shared implementation instead of two.
- **New: resource-scoped (`resource { link ... }`)** — a `Link` entry that appears only on that specific resource's own responses, not app-wide. No prior mechanism supported this. Enables cases like advertising an alternate representation available at the same URL via content negotiation (e.g. a JSON-LD document) without leaking that link onto every other resource in the app.
**Frank Core - `negotiate { }` Content Negotiation** ([#482](https://github.com/frank-fs/frank/issues/482))
- **New: the `negotiate { }` computation expression** (`Frank.Builder`, alongside `handler { }`) — genuine per-media-type content negotiation, producing a `HandlerDefinition list` (one per representation) that drops into any `get`/`post`/… slot on a resource, just as a single `handler { }` does. Each `accepts "<media-type>" <handler>` registers one *independent* representation; the one the request's `Accept` header selects is the only one whose handler ever runs. Selection follows RFC 9110 §12.5.1 — a representation's effective quality is taken from the **most specific** matching `Accept` entry, not the best-quality one, so `text/html;q=0` correctly overrides a broader `*/*;q=0.5` and vice versa, and `q=0` excludes a representation outright rather than merely deprioritizing it. Ties break by registration order; no match at all is `406 Not Acceptable` with no body; an absent, empty, or unparseable `Accept` is treated as an implicit `*/*` rather than a separate "default representation" concept. Every response — including the 406 — carries `Vary: Accept` (RFC 9110 §12.5.5).
- **`accepts` overloads:** a `RequestDelegate`, an `HttpContext -> unit`, a `handler { }`-built `HandlerDefinition` (whose `produces` metadata flows through to the generated OpenAPI document), or an `HttpContext -> Task<'a>`/`HttpContext -> Async<'a>` whose returned value is auto-formatted via `viaOutputFormatter` rather than silently discarded. A `mediaTypes: string list` batch form registers one representation per media type from a single shared handler: `accepts [ "application/json"; "application/xml" ] getProduct`.
- **Wildcard catch-all representations:** `accepts "*/*" ...` or `accepts "type/*" ...` registers a representation matching any client entry within that pattern — an ordinary entry in the same list, not a separate fallback mechanism. Because registration order breaks ties, **wildcards must be registered last** or they shadow every more specific representation after them. Frank never auto-sets `Content-Type` for a wildcard representation (a wildcard is not a valid `Content-Type`), which is exactly what lets one compose with the existing `ctx.Negotiate` for a full "delegate whatever's left to MVC's formatters" fallback. Pairing a wildcard with a *value-returning* handler is rejected at registration time with a clear exception — there is no concrete type to hand the formatter selector.
- **Media-type matching is exact for concrete types.** `MediaTypeHeaderValue.MatchesMediaType` is lenient about RFC 6839 structured-syntax suffixes in **both** directions; that leniency is now gated on the pattern side actually being a wildcard. Concrete-vs-concrete requires exact (case-insensitive) equality, so an `Accept: application/json` never silently receives a registered `application/ld+json` representation (nor the reverse), and a client's explicit ranking of `application/json` above `application/ld+json` is never inverted.
- **New: `Frank.ContentNegotiation.viaOutputFormatter mediaType body ctx`** — bridges a single representation to ASP.NET Core MVC's registered `IOutputFormatter` registry (`AddMvcCore()`, `AddXmlSerializerFormatters()`, …), writing `body` as exactly `mediaType` instead of requiring a hand-written producer. Unlike the existing `negotiate` function, the caller names the target media type rather than having it derived from `Accept`. Throws if no formatter is registered for that type (a server misconfiguration, not a client error, by the time it's called).
- **Naming caveat:** `Frank.Builder.negotiate` (the CE) and the pre-existing `Frank.ContentNegotiation.negotiate` function share an identifier. With both modules opened, F#'s ordinary shadowing rules apply and the last `open` wins — qualify one, or use the non-colliding `ctx.Negotiate(statusCode, body)` extension member. Documented in both signature files.
- **Sample:** `sample/Frank.OpenApi.Sample` demonstrates both a hand-written-producer `negotiate { }` block and one bridged to MVC's JSON/XML formatters via `viaOutputFormatter`.
**Frank Core - `negotiate { }` Dispatches at the Routing Layer**
- **`NegotiateBuilder.Run` now returns `HandlerDefinition list`** (was a single `HandlerDefinition`) — one per registered representation, each becoming its own `RouteEndpoint` at the same route and HTTP method, instead of one endpoint whose handler dispatched internally. Which endpoint serves a request is decided during endpoint selection by the new `FrankProducesMatcherPolicy`, the same stage at which ASP.NET Core's own `Accept-Encoding` negotiation runs — so the selected representation's endpoint is what authorization, `IEndpointFilter`s, `Frank.JsonHome`, `Frank.Alps` and the OpenAPI document all see, rather than a single opaque wrapper. Behavior a client observes (RFC 9110 §12.5.1 selection, bodyless 406, `Vary: Accept`, `Content-Type` from the winning representation) is unchanged.
- **Binary-breaking, source-compatible for the standard authoring pattern.** `negotiate { accepts "…" … }` passed straight into `get`/`post`/`put`/`delete`/`patch`/`head`/`options` still compiles unchanged — those operations gained `HandlerDefinition list` overloads alongside their existing `HandlerDefinition` ones. It is NOT source-compatible for code that reached into a `negotiate { }` result directly (`.Handler`/`.Metadata` on what is now a list), nor for code that built a `ResourceSpec.Handlers` list literal by hand: that tuple changed from `(string * RequestDelegate)` to `(string * RequestDelegate * obj list)`, so each handler entry carries its own metadata instead of every handler sharing one per-method convention list.
- **Metadata is per representation, not pooled.** Each representation's endpoint carries its own metadata plus the shared, merged `produces` metadata (broadcast so the generated OpenAPI document lists the full content-type union on every one). Non-`produces` metadata — an ALPS `Descriptor` from `binds`, a `Frank.Auth` requirement, anything an extension library attaches — stays on the representation that declared it and is never copied onto its siblings.
- **New public types in `Frank.Builder`:** `MediaTypeNegotiation` (the RFC 9110 §12.5.1 media-type matching and quality-value functions, now a shared module rather than private helpers inside `NegotiateBuilder`), `ProducesMediaTypeMetadata` (the per-representation marker the policy reads), and `FrankProducesMatcherPolicy` (the `MatcherPolicy` itself). `webHost { }` registers the policy automatically — no configuration needed.
- **Caveat — hand-rolled hosts must register the policy.** An app that does NOT build its host through `webHost { }` (a hand-written `WebApplication`/`IHost` wiring Frank resources into `UseEndpoints` itself) must register the policy explicitly:
```fsharp
services.AddSingleton<MatcherPolicy, FrankProducesMatcherPolicy>()
```
Without it, any `negotiate { }` block with more than one representation throws `AmbiguousMatchException` at request time, because the representations are now genuinely several endpoints on one route and nothing is left to choose between them. This is a regression from earlier releases, where a single endpoint dispatched internally and therefore worked regardless of host setup.
- **`HandlerDefinitionMetadata.toConventions` removed** — it projected a `HandlerDefinition`'s metadata into `EndpointBuilder` conventions, which nothing needs now that handler entries carry their own metadata; it had no remaining callers.
**Frank.Analyzers - FRANK002 Duplicate Accepts Media Type** ([#482](https://github.com/frank-fs/frank/issues/482))
- **New rule `FRANK002`:** warns when the same media type is registered by more than one `accepts` operation inside a single `negotiate { }` block. The earlier registration always wins on the tie-break, making the later one dead code. Emitted by the existing `DuplicateHandlerAnalyzer` (which now covers both duplicate HTTP-method handlers and duplicate `accepts` media types) — no new analyzer assembly, no new package reference.
**Frank.OpenApi - service-desc Link Header**
- **Responses advertise the OpenAPI document:** apps with `useOpenApi` enabled carry a `Link: <path>; rel="service-desc"; type="application/json"` response header (RFC 8631), so clients can discover the machine-readable OpenAPI document without prior knowledge of its route. Delivered through Frank core's shared `link` mechanism (see Frank Core above) rather than a private implementation — no change in the header a client sees.
**Frank.JsonHome - JSON Home Document Support**
- **New Library:** Frank.JsonHome extension library serving a [JSON Home](https://datatracker.ietf.org/doc/html/draft-nottingham-json-home-06) document describing an application's entry-point resources. Zero NuGet dependencies — only `FrameworkReference Microsoft.AspNetCore.App` and a project reference to `Frank`; no dependency on `Frank.Auth` or `Frank.OpenApi`.
- **WebHostBuilder Integration:** `useJsonHome` operation serves the document at `/.well-known/home.json` (configurable) and advertises it with a `Link: rel="home"` response header, delivered through Frank core's shared `link` mechanism (see Frank Core above) — same `plugBeforeRouting` ordering caveat as `Frank.OpenApi`'s `service-desc` header above.
- **ResourceBuilder Extensions:** `rel`, `hrefVar`, `docs`, `deprecated`, `gone`, `acceptRanges`, `acceptPrefer`, `preconditionRequired`, and `authScheme` declare resource-level discovery metadata. Resources without a `rel` are omitted from the document — JSON Home is a curated entry point, not a sitemap.
- **draft-06 Compliant:** all eleven resource hints implemented (`allow`, `formats`, `acceptPatch`, `acceptPost`, `acceptPut`, `acceptRanges`, `acceptPrefer`, `docs`, `preconditionRequired`, `authSchemes`, `status`), camelCase hint names throughout, and `hrefVars` always present alongside `hrefTemplate` per the spec's MUST.
- **Authorization Filtering:** reads stock `IAuthorizeData`/`AuthorizationPolicy` endpoint metadata, so it works with `Frank.Auth` without referencing it and equally with a plain `AuthorizeAttribute`. Evaluation failures deny. The document is dispatched as a real endpoint through the same routing stage as every other resource — after any `useAuthentication`/`useAuthorization` middleware, regardless of where `useJsonHome` appears in the `webHost { }` block — so filtering sees the real principal rather than an anonymous one. Emits `Cache-Control: private, no-cache` and `Vary: Authorization` whenever any resource is guarded. Filtering is per HTTP method, not per whole resource: a resource whose `DELETE` requires a role it doesn't have but whose `GET` is public now shows `["GET"]` in `hints.allow` rather than disappearing entirely (previously any guarded method hid the whole resource). A resource left with no visible methods for the current principal is omitted entirely, as before.
- **Route Template Translation:** ASP.NET route templates (`{id:guid}`, `{id?}`, `{*rest}`) are translated to RFC 6570 URI Templates for `hrefTemplate`.
- **Duplicate `rel` fails startup** ([#475](https://github.com/frank-fs/frank/issues/475)): `useJsonHome` registers an `IStartupFilter` that throws `OptionsValidationException` when two or more resources declare the same `rel`, naming every colliding route template. `resources` in the served document is a JSON object keyed by `rel`, so a collision would otherwise silently drop one resource's *entry from the discovery document* (the resource itself stays reachable over HTTP) with no diagnostic. No opt-in: every `useJsonHome` call gets the check. It runs after the endpoint pipeline is built but before the server accepts a request — `IApiDescriptionGroupCollectionProvider` does not reflect Frank's resources any earlier than that, so `AddOptionsWithValidateOnStart` is deliberately *not* the mechanism.
- **Mismatched `hrefVar` fails startup, and at compile time too** ([#474](https://github.com/frank-fs/frank/issues/474)): two independent checks catch a `hrefVar` that doesn't match its resource's route template variables — the motivating case being a typo like `hrefVar "prodId" ...` on `/products/{id}`, which previously produced a `hrefVars` entry that resolved nothing, silently. At compile time, `Frank.Analyzers`' new `FRANK003` rule reports the mismatch at the `hrefVar`/`resource` call site (`Severity.Error`), in both directions — an undeclared template variable and a declared-but-unmatched `hrefVar`. At startup, `useJsonHome` registers a second `IStartupFilter` (`HrefVarStartupFilter`, alongside #475's `DuplicateRelStartupFilter`) that throws `HrefVarValidationException` listing every mismatched resource, same `next.Invoke(app)`-then-check timing as #475 for the same reason. Both mechanisms share one diff function (`HrefVarValidation.diff`) — the analyzer links its source directly rather than taking a `ProjectReference`, so `Frank.Analyzers` still has no dependency on `Microsoft.AspNetCore.App`.
- **No Breaking Changes:** entirely additive — no changes to `Frank` core.
- **Sample:** `sample/Frank.JsonHome.Sample` demonstrates the discovery metadata and, curling the same `/.well-known/home.json` as anonymous vs. an authenticated admin, the authorization filtering actually changing what the document lists.
**Example Usage:**
```fsharp
webHost {
useJsonHome
resource "/products" {
rel "tag:example.com,2026:products"
get listProducts
}
resource "/products/{id}" {
rel "tag:example.com,2026:product"
hrefVar "id" "https://example.com/param/product-id"
requireRole "admin"  // Frank.Auth, filtered from the document for other principals
get getProduct
}
}
```
**Frank.Auth - Handler-Level Authorization**
- **New: handler-level `requireAuth`/`requireClaim`/`requireRole`/`requirePolicy`/`allowAnonymous`** — the same four authorization operations already available on `ResourceBuilder`, now also usable inside a `handler { }` block, so a single HTTP method can carry its own requirement independent of the resource (e.g. `GET` public, `DELETE` admin-only on the same resource). Composes additively (AND) with any resource-level requirement on the same endpoint via ASP.NET Core's own multi-`IAuthorizeData` combination — no new mechanism, no Frank core change.
- **New: `allowAnonymous`** — bypasses ALL authorization on that one handler, resource-level and handler-level alike, via stock ASP.NET Core `AllowAnonymousAttribute`/`IAllowAnonymous` semantics. This is a binary bypass, not a policy downgrade: co-declaring `allowAnonymous` with a handler-level requirement on the same handler means the requirement is never evaluated, matching `[AllowAnonymous]`'s real behavior in ASP.NET Core MVC.
**Frank.Rdf - Hand-Authored RDF/JSON-LD**
- **New Library:** Frank.Rdf provides an `rdf { }` computation expression for hand-authoring RDF triples across one or more resources, serialized to JSON-LD in expanded form. Zero ASP.NET Core dependency — no `ProjectReference` to `Frank`, no `FrameworkReference` to `Microsoft.AspNetCore.App`; the only NuGet dependency is `dotNetRdf.Core 3.5.1`. It builds and serializes documents; it has no opinion on how a handler returns the result.
- **`describe`/`about` mirrors `handler { }`/`get`:** `describe (Node.Iri "https://example.org/g1") { typ "schema:Game"; propertyString "schema:name" "Tic-tac-toe" }` runs to completion as a self-contained `DescribeBuilder`, producing a plain `Description`; `rdf { }`'s `about` operation consumes that value directly, the same two-CE composition pattern Frank core already uses for `handler { }` feeding `resource { }`'s `get`. No `Combine`/`Delay` on either builder. `rdf { }` also has a bare `triple subject predicate value` operation for one-off statements, and `prefix` to declare CURIE namespace mappings. Properties are five distinct operations — `propertyString`/`propertyInt`/`propertyBool`/`propertyDateTime`/`propertyNode`, chosen by the value's type — rather than one overloaded `property`: F#'s custom-operation overload resolution commits to a single resolved parameter type for the whole CE block once the first call type-checks, so an overloaded `property` failed to compile reliably across calls with different value types in the same block.
- **CURIE resolution is declared-prefix-first:** a string is resolved against a declared `prefix` before falling back to "is this already a well-formed absolute URI" — the other order is a real bug, not a style choice, since `.NET`'s `Uri` parser accepts unrecognized schemes (`"schema:Game"` parses as well-formed with scheme `"schema"`), which would otherwise let the single most common kind of CURIE this CE exists to construct silently pass through unresolved. This ordering fixes that specific bug — a declared prefix always wins over accidental well-formedness.
- **Undeclared-prefix fallback tightened** ([#484](https://github.com/frank-fs/frank/issues/484)): passing through an undeclared prefix as an absolute IRI now additionally requires the string to *look* genuinely absolute — the text immediately after the parsed scheme's colon must start with `//` (anchored, not merely `://` appearing anywhere later in the string), or the string must start with an allow-listed non-hierarchical scheme (`urn:`, `mailto:`, `tel:`, matched case-insensitively per RFC 3986 §3.1) — before `Uri.IsWellFormedUriString` is even consulted. Previously, any `word:word`-shaped string satisfied .NET's lax well-formedness rules regardless of scheme, so a typo like `foaf:name` with no `foaf` prefix declared silently became the literal (wrong) IRI `<foaf:name>` instead of raising, and a stray leftover prefix like `schema:http://weird` would have slipped through too. This is a deliberate behavior tightening: such typos now raise `Frank.Rdf: undeclared prefix '...' in '...'` instead of silently passing through. Legitimate absolute-IRI passthrough (`http://...`, `https://...`, and the allow-listed schemes above, in any case) is unaffected.
- **`Doc.toGraph`/`Doc.writeJsonLd`/`Doc.toJsonLd`:** wrap dotNetRDF for graph construction and JSON-LD writing. `writeJsonLd` takes the caller's own `TextWriter` (never closing or disposing it) so a response handler can stream straight into `HttpResponse.Body` without materializing the whole document as a string first; `toJsonLd` is a thin `string`-returning convenience wrapper for callers that need one (tests, mainly).
- **Output is expanded-form JSON-LD only:** no `@context`, no compaction, every predicate and type expanded to its absolute IRI. Deliberate, not a placeholder — see the design doc's *Serialization* section.
- **`Node.blank ()` mints a GUID, not a per-`Doc` counter** — the specific choice that makes `Doc.merge`/`includeDoc` (combining two independently-built documents) safe: two docs that each mint their own blank nodes can never collide when merged, which a counter-based scheme could not guarantee.
- **No breaking changes:** entirely additive, new package.
- **Sample:** `sample/Frank.Rdf.Sample` demonstrates the CE across two subjects (a game and its `numberOfPlayers` value), `Doc.merge` folding in facts shared across every resource (a publisher record merged into each game's document), and `Doc.writeJsonLd` streaming straight into the response body. `GET /games/{id}` now demonstrates real `Accept`-based content negotiation: a `negotiate { }` block serves a plain-JSON DTO representation (`application/json`, also the default with no `Accept` header) and the JSON-LD representation (`application/ld+json`) at the same url, plus a resource-scoped `link` operation advertising the JSON-LD alternate via a `Link: </games/{id}>; rel="alternate"; type="application/ld+json"` header on every response from that resource, found or not.
**Frank.Provenance - PROV-O Provenance**
- **New Library:** Frank.Provenance records and queries [PROV-O](https://www.w3.org/TR/prov-o/) provenance -- an `Agent` performing an `Activity` that generated a `Resource` (Entity), with `startedAtTime`/`endedAtTime` and optional domain-specific `ActivityType`/`Properties` -- via the `ProvenanceRecord` type and `IProvenanceStore` interface, built directly on `Frank.Rdf`'s `Doc`/`Description` model rather than a parallel triple representation. Zero ASP.NET Core dependency, same as `Frank.Rdf` itself -- no `ProjectReference` to `Frank`, no `FrameworkReference` to `Microsoft.AspNetCore.App`; the only new NuGet dependency beyond `Frank.Rdf`'s own is `Microsoft.Extensions.Logging.Abstractions`.
- **Closed query vocabulary:** `ProvenanceQuery` (`ByResource`/`ByAgent`/`ByActivityId`) is the *only* way a caller queries a store -- there is no public API accepting a raw `SparqlQuery` or query string. Each case compiles to a pre-built, parameterized SPARQL `CONSTRUCT`/`DESCRIBE` query internally; adding a new provenance-meaningful query shape means adding a case to this closed vocabulary, not widening the surface to open query text.
- **`MailboxProcessorProvenanceStore`:** an in-memory `IProvenanceStore` backing every appended record with its own named graph in a single dotNetRDF `TripleStore`, queried over the store's union graph. A `MailboxProcessor` serializes all `Append`/`Query` access, so concurrent callers never race; a malformed record (e.g. a relative-IRI `Activity`) is caught, logged, and dropped rather than killing the mailbox loop and silently hanging every later `Query`. `ProvenanceStoreConfig` bounds the store (`MaxRecords`, `EvictionBatchSize`), evicting the oldest records once the bound is exceeded; eviction is clamped so the record just appended is never evicted in the same call, regardless of how those two values are configured.
- **Opt-in durability via `IProvenanceJournal`:** a new three-member interface (`Append`/`Snapshot`/`Recover`) passed as an optional third constructor argument to `MailboxProcessorProvenanceStore`. With one attached, every `Append` is durably logged, every `ProvenanceStoreConfig.SnapshotEvery` appends (new field, default 100) the store's current state is compacted into a snapshot, and construction replays the latest snapshot plus everything journaled since it -- so a restarted process with the same journal picks up where the last one left off. Omit the argument and behavior is bit-for-bit the v1 in-memory store: no files, no cost. Journal writes are fire-and-forget and best-effort -- a failing journal is logged and swallowed, never blocking, failing, or un-recording a caller's `Append`.
- **`FileProvenanceJournal`:** the in-box implementation, writing N-Quads segment/snapshot files (`{actorId}.journal.{seq}.nq` / `{actorId}.snapshot.{seq}.nq`) under a caller-supplied directory, tracked by an `{actorId}.manifest.json` pointer file written atomically (temp-file-then-rename). Segment and snapshot files are immutable and versioned -- never overwritten, never deleted, not even once a snapshot supersedes them. Its own writes are serialized by a `MailboxProcessor`, and a write failure (full disk, denied permission, corrupt manifest) is logged through an optional `ILogger` and absorbed rather than killing that loop.
- **`ProvBuilder`:** two authoring surfaces, one value -- plain `|>` combinators over `Prov`'s functions, or an equivalent `activity`/`entity`/`agent` computation expression (`activity a { wasAssociatedWith agentNode; startedAtTime t0; endedAtTime t1 }`), produce structurally identical `Description`s. Mirrors `Frank.Rdf`'s `describe`/`DescribeBuilder` and `Frank.Alps`'s `descriptor`/`DescriptorBuilder` exactly.
- **No breaking changes:** entirely additive, new package.
- **Sample:** `sample/Frank.Provenance.Sample` demonstrates recording a `ProvenanceRecord` on every real request to `/games/{id}` and querying it back via `GET /provenance?resource=`, plus `GET /provenance/lineage`, which hand-authors a `wasDerivedFrom` relationship via `ProvBuilder` -- a PROV-O shape `ProvenanceRecord`/`IProvenanceStore.Append` can't express.
**Frank.Alps - Hand-Authored ALPS Profiles**
- **New Library:** Frank.Alps serves [ALPS](https://datatracker.ietf.org/doc/draft-amundsen-richardson-foster-alps/) (draft-07) profile documents describing what an application's resources mean and which transitions are available from them. Zero NuGet dependencies — only `FrameworkReference Microsoft.AspNetCore.App` and a project reference to `Frank`; no dependency on `Frank.Auth`, `Frank.Rdf`, or `Frank.OpenApi`. Profiles are hand-authored F# `Descriptor` values, never derived from CLR types or view templates.
- **Two authoring surfaces, one value:** plain `|>` combinators (`semantic "game" |> doc "A tic-tac-toe game" |> contains [ viewGame; makeMove ]`) and an equivalent `descriptor { }` computation expression (`descriptor "makeMove" { unsafe; from [ openState ]; rt closedState }`) produce structurally identical `Descriptor`s. `semantic`/`safe`/`unsafe`/`idempotent` do double duty as plain constructors outside a block and as zero-argument custom operations inside one, the same trick F#'s own `query { }` uses for `distinct`. Full draft-07 field coverage: `id`, `name`, `type`, `def`, `doc` (with `href`/`format`/`contentType`), `ext`, `href`, `link`, `rel`, `rt`, `tag`, and nested `descriptor`.
- **Compile-checked references:** `rt`, `from`, `href`, and `contains` take `Descriptor` values, so a dangling reference is a compile error rather than a wrong document. Plain strings only where there is genuinely nothing to check against — `hrefExternal`, `def`, and a descriptor's own id.
- **`binds` ties a transition to the endpoint that implements it,** inside a `handler { }` block, via Frank core's open `HandlerDefinition.Metadata` list. That binding drives everything else: a transition's authored `type` is validated at startup against its bound HTTP method (`safe` → GET/HEAD, `idempotent` → PUT/DELETE, `unsafe` → POST), failing host startup on a mismatch rather than serving a wrong document; and authorization filtering reads that endpoint's own metadata. A transition the profile names but nothing binds is logged as a startup warning and omitted, rather than vanishing silently.
- **Authorization Filtering:** reads stock `IAuthorizeData`/`AuthorizationPolicy` endpoint metadata, so it works with `Frank.Auth` without referencing it and equally with a plain `AuthorizeAttribute`. Evaluation failures deny. Filtering applies at every depth of the authored tree, not just its top level — a guarded transition nested under a semantic state via `contains` is hidden from principals who may not invoke it, while the semantic parent itself is still served. Semantic descriptors are never filtered by either mechanism: vocabulary, not capability.
- **Two HTTP exposures.** `useAlps [ ... ]` serves the whole profile at `/.well-known/alps.json` (configurable) with a `Link: rel="profile"` header, filtered by authorization only — there is no resource instance in scope to have a state. `Alps.excerpt resolver`, wired into a `negotiate { }` block's `accepts "application/alps+json"` case, serves just the transitions bound to that resource's route (every HTTP method's, not only the one it runs under), filte

...(truncated -- full release notes: https://github.com/frank-fs/frank/blob/master/RELEASE_NOTES.md)