ForgeTrust.RazorWire 0.1.0-rc.1

This is a prerelease version of ForgeTrust.RazorWire.
dotnet add package ForgeTrust.RazorWire --version 0.1.0-rc.1
                    
NuGet\Install-Package ForgeTrust.RazorWire -Version 0.1.0-rc.1
                    
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="ForgeTrust.RazorWire" Version="0.1.0-rc.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ForgeTrust.RazorWire" Version="0.1.0-rc.1" />
                    
Directory.Packages.props
<PackageReference Include="ForgeTrust.RazorWire" />
                    
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 ForgeTrust.RazorWire --version 0.1.0-rc.1
                    
#r "nuget: ForgeTrust.RazorWire, 0.1.0-rc.1"
                    
#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 ForgeTrust.RazorWire@0.1.0-rc.1
                    
#: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=ForgeTrust.RazorWire&version=0.1.0-rc.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=ForgeTrust.RazorWire&version=0.1.0-rc.1&prerelease
                    
Install as a Cake Tool

RazorWire

RazorWire lets ASP.NET Core MVC apps update UI by returning Razor fragments from the server instead of building a separate JSON endpoint and client-state rendering loop.

60-Second Quickstart

AppSurface has not published the public v0.1 package set yet, so the copy-paste path today is repo-local:

  1. Clone this repository and use the .NET 10 SDK.
  2. Run the MVC sample:
dotnet run --project examples/razorwire-mvc/RazorWireWebExample.csproj
  1. Open the URL printed in the console and navigate to /Reactivity.

Wait for the Permanent Island card to load, then click the + button. The Instance Score and Session Score update in place without a full-page reload.

When consuming package builds from a configured feed, reference ForgeTrust.RazorWire first and then continue at Add the Module. Public NuGet install commands will replace this note when the v0.1 publishing path is live.

Release Guidance

AppSurface has cut the first coordinated v0.1.0 release candidate. Before installing this package from a prerelease feed, read the v0.1.0 RC 1 release note for current release risk, migration guidance, and package readiness.

Hero Proof

examples/razorwire-mvc/Views/Shared/Components/Counter/Default.cshtml

<div id="counter-widget" class="p-4 bg-white border border-slate-100 rounded-xl shadow-sm flex items-center justify-between group">
    <div class="flex gap-6">
        <div class="space-y-0.5">
            <span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Instance Score</span>
            <div id="instance-score-value" class="text-2xl font-black text-indigo-600 tabular-nums">@Model</div>
        </div>
        <div class="space-y-0.5">
            <span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Session Score</span>
            <div id="session-score-value" class="text-2xl font-black text-indigo-400 tabular-nums">0</div>
        </div>
    </div>

    <form asp-controller="Reactivity" asp-action="IncrementCounter" method="post" rw-active="true" data-counter-form>
        <input type="hidden" name="clientCount" id="client-count-input" value="0" />
        <button type="submit" aria-label="Increment counter" class="h-10 w-10 bg-indigo-600 text-white rounded-lg flex items-center justify-center hover:bg-indigo-700 active:scale-90 transition-all shadow-sm shadow-indigo-100">
            <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
        </button>
    </form>
</div>

examples/razorwire-mvc/Controllers/ReactivityController.cs

[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult IncrementCounter([FromForm] int clientCount)
{
    CounterViewComponent.Increment();
    clientCount++;

    if (Request.IsTurboRequest())
    {
        return this.RazorWireStream()
            .Update(
                "instance-score-value",
                CounterViewComponent.Count.ToString())
            .Update("session-score-value", clientCount.ToString())
            .ReplacePartial(
                "client-count-input",
                "_CounterInput",
                clientCount)
            .BuildResult();
    }

    // Safe redirect
    var referer = Request.Headers["Referer"].ToString();

    return Url.IsLocalUrl(referer) ? Redirect(referer) : RedirectToAction(nameof(Index));
}

examples/razorwire-mvc/Views/Reactivity/_CounterInput.cshtml

<input type='hidden' name='clientCount' id='client-count-input' value='@Model' />

Read the focused proof path for the file-by-file walkthrough. If copying this pattern gives you a bare 400 Bad Request, anti-forgery is the first thing to check. See Security & Anti-Forgery.

The source-backed snippets in this README are generated from docs:snippet markers in the sample app. After changing marked sample code, run:

# From the repository root:
dotnet run --project tools/ForgeTrust.AppSurface.MarkdownSnippets/ForgeTrust.AppSurface.MarkdownSnippets.csproj -- generate

For failed submissions, RazorWire also ships a convention-based form UX stack: default form-local fallbacks for unhandled failures, server helpers for validation errors, anti-forgery diagnostics in development, and styling/event hooks for consumers. See Failed Form UX or run the sample and visit /Reactivity/FormFailures.

Generated UI Design Contract

RazorWire should feel like a quiet enhancement inside the host application, not like a separate visual product placed on top of it. Package-owned generated UI follows the RazorWire generated UI design contract.

Use that contract when adding or styling RazorWire-generated nodes such as form feedback, stream status affordances, or package-owned fallback UI. It defines the scope boundary, data-attribute and CSS custom-property styling surface, accessibility baseline, override model, and anti-patterns. It does not apply to app-authored forms, partials, layouts, or AppSurface Docs chrome.

Add the Module

Once you already reference the RazorWire package in your app, add RazorWireWebModule to your root module:

public class MyRootModule : IAppSurfaceWebModule
{
    public void RegisterDependentModules(ModuleDependencyBuilder builder)
    {
        builder.AddModule<RazorWireWebModule>();
    }
}

Enable TagHelpers and Scripts

RazorWire markup only lights up when your views import the package TagHelpers and your shared layout renders the client scripts once. Without this step, rw:island, rw:stream-source, and rw-active forms fall back to plain HTML behavior.

examples/razorwire-mvc/Views/_ViewImports.cshtml

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, ForgeTrust.RazorWire

examples/razorwire-mvc/Views/Shared/_Layout.cshtml

<rw:scripts/>

Configure Services (Optional)

You can customize RazorWire behavior via RazorWireOptions:

services.AddRazorWire(options =>
{
    options.Streams.BasePath = "/custom-stream-path";
    options.Streams.AuthorizationMode = RazorWireStreamAuthorizationMode.DenyAll;
    options.Streams.MaxChannelNameLength = 128;
    options.Streams.MaxLiveChannels = 64;
    options.Streams.MaxLiveSubscriptions = 256;
    options.Streams.MaxLiveSubscriptionsPerChannel = 32;
    options.Forms.FailureMode = RazorWireFormFailureMode.Auto;
    options.Forms.DefaultFailureMessage = "We could not submit this form. Check your input and try again.";
});

Stream subscriptions are denied by default. Choose AllowAll only for public/demo streams with finite channel names and explicit per-process limits:

services.AddRazorWire(options =>
{
    options.Streams.AuthorizationMode = RazorWireStreamAuthorizationMode.AllowAll;
    options.Streams.MaxLiveChannels = 8;
    options.Streams.MaxLiveSubscriptions = 100;
    options.Streams.MaxLiveSubscriptionsPerChannel = 25;
});

These limits are admission guardrails for one ASP.NET Core application process. They are not tenant quotas, user quotas, IP fairness, load-balancer limits, or cluster-wide counters. Use ASP.NET Core rate limiting, reverse-proxy connection limits, SignalR, or managed pub/sub when public traffic needs client fairness, distributed fanout, groups, durable delivery, or cross-node capacity planning.

For user, tenant, or workflow-specific streams, register a custom authorizer instead:

public sealed class TenantStreamAuthorizer : IRazorWireChannelAuthorizer
{
    public ValueTask<bool> CanSubscribeAsync(HttpContext context, string channel)
    {
        var tenantId = context.User.FindFirst("tenant_id")?.Value;
        return new ValueTask<bool>(tenantId is not null && channel == $"tenant:{tenantId}:updates");
    }
}

services.AddSingleton<IRazorWireChannelAuthorizer, TenantStreamAuthorizer>();
services.AddRazorWire();

Package-owned sensitive streams may impose stricter rules than the global RazorWire AllowAll shortcut. For example, AppSurface Docs harvest progress requires a custom host authorizer outside Development even when the host exposes docs harvest health routes.

Also Possible

  • Keep sidebars and other regions independent with rw:island, including lazy loading and permanent="true" persistence across page transitions.
  • Push live updates to connected clients with IRazorWireStreamHub and rw:stream-source.
  • Return form updates from normal MVC controllers with this.RazorWireStream(), not a separate JSON API.
  • See the broader RazorWire MVC Example for registration, message publishing, islands, and SSE.
  • See Failed Form UX for server failure conventions, customization, and diagnostics.
  • See Security & Anti-Forgery for the form-update patterns that matter in production.

Core Concepts

Islands

Islands are isolated regions of a page that can load, reload, or update independently. RazorWire renders them as Turbo Frames, so you can decompose a page into smaller Razor-backed units without introducing a separate frontend app.

Streams and SSE

RazorWire can push Turbo Stream updates to one or more clients over Server-Sent Events. That makes it a good fit for counters, feeds, presence lists, and other UI that should update live while staying server-rendered.

RazorWire can also send a narrow same-origin visit command with Visit(...). Visit streams are one-shot navigation commands, not replayable state. Use them for active subscribers only, and keep normal links or retained state available when late subscribers or no-JavaScript users need to continue.

The in-memory stream hub keeps live subscription tracking separate from opt-in replay retention. Empty live channel tracking is released after the last subscriber disconnects or publish-time cleanup prunes stale writers. Retained replay buffers are not deleted by live disconnects; they stay bounded by the replay retention policy.

Before a request reaches the hub, the RazorWire endpoint applies single-process admission guardrails:

  • Channel names, after URL decoding, may contain only ASCII letters, digits, ., _, -, and : and must fit MaxChannelNameLength.
  • Invalid or overlong channels return 400 before custom authorizers run.
  • Authorization denials return 403 and do not consume admission capacity.
  • Capacity denials return 429 before SSE headers are written and before IRazorWireStreamHub.Subscribe(...) is called.
  • MaxLiveChannels, MaxLiveSubscriptions, and MaxLiveSubscriptionsPerChannel are per-process limits. One browser tab or live SSE request consumes one subscription slot.

Text vs Trusted HTML in Stream Templates

RazorWire stream templates have a deliberate trust boundary:

API Template content handling Use when
Append, Prepend, Replace, Update Treats content as plain text and HTML-encodes it. null renders as empty text. Updating counters, status labels, validation targets, and any caller-supplied text.
AppendHtml, PrependHtml, ReplaceHtml, UpdateHtml Writes trustedHtml directly. RazorWire does not encode or sanitize it. Inserting small server-authored fragments where every user value has already been encoded.
AppendPartial, PrependPartial, ReplacePartial, UpdatePartial Renders a Razor partial through MVC. Returning app markup that belongs in a .cshtml partial.
AppendComponent, PrependComponent, ReplaceComponent, UpdateComponent Renders a view component through MVC. Returning reusable app markup with component logic.
new RazorWireStreamResult(rawContent) and IRazorWireStreamHub.PublishAsync(channel, content) Raw whole-stream boundaries. Content is transported as-is. Advanced integrations that already built trusted Turbo Stream markup.

Prefer the text APIs by default:

return this.RazorWireStream()
    .Update("status", displayName)
    .BuildResult();

If displayName is <b>Ada</b>, the browser receives text, not markup:

<template>&lt;b&gt;Ada&lt;/b&gt;</template>

For app-authored markup, prefer Razor-rendered helpers:

return this.RazorWireStream()
    .ReplacePartial("profile-card", "_ProfileCard", model)
    .BuildResult();

Use *Html only when the string is already trusted and caller-owned. Encode user values before composing the fragment:

var encodedName = System.Net.WebUtility.HtmlEncode(displayName);

return this.RazorWireStream()
    .UpdateHtml("status", $"<p>Saved {encodedName}.</p>")
    .BuildResult();

Form Enhancement

Standard HTML forms can return targeted stream updates instead of full reloads or redirect-first flows. The counter example above is the smallest version of that story: submit a normal MVC form, return RazorWire updates, and change only the DOM you care about.

When EnableFailureUx is enabled, form[rw-active] also marks enhanced form posts with X-RazorWire-Form: true and __RazorWireForm=1. That gives the runtime and server adapters enough context to render useful failed-submission UX without every controller hand-rolling client glue.

Security & Anti-Forgery

Handling anti-forgery tokens correctly is critical when updating forms via Turbo Streams. See Security & Anti-Forgery for the detailed patterns and recommendations.

RazorWire stream subscriptions are also safe by default: RazorWireOptions.Streams.AuthorizationMode starts at RazorWireStreamAuthorizationMode.DenyAll, so rw:stream-source receives 403 until the app either opts into RazorWireStreamAuthorizationMode.AllowAll for public/demo channels or registers IRazorWireChannelAuthorizer. Development responses include safe plain-text diagnostics for 400, 403, and 429; production denials stay generic and logs avoid raw channel names, user identifiers, and claim values.

Native EventSource does not expose failed HTTP response bodies or status codes to application JavaScript. RazorWire dispatches razorwire:stream:error from registered rw-stream-source elements when the browser reports a stream error. Use that event for client-side diagnostics, and use the browser Network tab plus server log event 13700 StreamSubscriptionDenied for authorization denials (403) and 13701 StreamAdmissionRejected for validation and capacity rejections (400/429) during development.

RazorWire also emits low-cardinality System.Diagnostics.Metrics instruments for stream admission: razorwire.stream.live_subscriptions, razorwire.stream.live_channels, and razorwire.stream.admission.rejections tagged by rejection reason. These metrics are process-local diagnostics for tuning limits; they are not an abuse-mitigation layer.

Stream builder text APIs encode template content by default. The *Html builder methods, RazorWireStreamResult(string?), and IRazorWireStreamHub.PublishAsync(channel, content) are trusted boundaries: they do not encode or sanitize raw markup. Keep user-controlled values in text APIs, Razor partials, or view components unless you explicitly encode them before composing trusted HTML.

Development anti-forgery failures from RazorWire forms are rewritten into helpful form-local diagnostics when possible. Production responses stay safe and generic. See Failed Form UX.

Development Experience

RazorWire is designed for a fast feedback loop during development:

  • Razor Runtime Compilation is automatically enabled in Development, so you can edit .cshtml files and refresh without rebuilding.
  • Local scripts and styles automatically receive version hashes for cache busting, even without asp-append-version="true".

API Reference

RazorWireBridge

  • Frame(controller, id, viewName, model) returns a partial view wrapped in a <turbo-frame> with the specified ID.
  • FrameComponent(controller, id, componentName) renders a view component inside a <turbo-frame>.

IRazorWireStreamHub

  • PublishAsync(channel, content) broadcasts a trusted Turbo Stream fragment to every subscriber on a channel. The hub transports content as-is; it does not encode or sanitize template content.
  • PublishAsync(channel, content, new RazorWireStreamPublishOptions { Replay = true }) broadcasts the trusted fragment and retains it in the channel's bounded replay buffer.
  • Subscribe(channel) receives only live messages published after subscription.
  • Subscribe(channel, new RazorWireStreamSubscribeOptions { Replay = true }) receives retained replay messages first, then continues with live messages.

Replay is opt-in and intentionally small. The in-memory hub keeps up to 25 retained fragments per replay channel and prunes inactive replay channels when more than 256 replay channels are retained, dropping the oldest retained fragments and inactive replay channels first. Replay subscriptions to channels with no retained messages do not allocate durable per-channel replay metadata. Use replay for idempotent state snapshots, progress indicators, and other "latest known UI" streams where a late subscriber should catch up. Do not use replay for one-time commands, sensitive personal data, secrets, or unbounded event logs.

Endpoint admission is separate from hub delivery. The default endpoint validates channel names, authorizes, and admits a live subscription before calling Subscribe. Apps that call a custom IRazorWireStreamHub directly do not get endpoint admission automatically.

RazorWireStreamOptions

  • BasePath: endpoint base path; defaults to /_rw/streams. It must be an absolute path without a trailing slash, route tokens, query string, fragment, whitespace, or control characters.
  • AuthorizationMode: defaults to DenyAll.
  • MaxChannelNameLength: defaults to 128 decoded characters.
  • MaxLiveChannels: defaults to 64 live channel names per process.
  • MaxLiveSubscriptions: defaults to 256 live SSE requests per process.
  • MaxLiveSubscriptionsPerChannel: defaults to 32 live SSE requests for one channel per process.

All numeric admission limits must be greater than zero and are validated at startup. Raising them increases connection, memory, and request-slot pressure in the current process; it does not create distributed fairness.

IRazorWireChannelAuthorizer

  • CanSubscribeAsync(HttpContext, channel) decides whether the current request may subscribe to a stream channel.
  • The built-in DenyAllRazorWireChannelAuthorizer is selected by default through RazorWireOptions.Streams.AuthorizationMode = RazorWireStreamAuthorizationMode.DenyAll.
  • AllowAllRazorWireChannelAuthorizer is selected by RazorWireOptions.Streams.AuthorizationMode = RazorWireStreamAuthorizationMode.AllowAll and should only be used for public/demo streams.
  • Register a custom implementation for auth-context-aware decisions based on HttpContext.User, claims, tenant membership, workflow state, or route data.

RazorWireStreamAuthorizationMode

  • DenyAll = 0: default; every subscription returns 403 unless a custom IRazorWireChannelAuthorizer is registered.
  • AllowAll = 1: permits every subscription; intended for public/demo streams only.
  • Unknown enum values fail with a clear configuration exception instead of falling through to an unsafe mode.

this.RazorWireStream() (controller extension)

  • Append(target, content) adds HTML-encoded text to the end of the target element.
  • Prepend(target, content) adds HTML-encoded text to the beginning.
  • Replace(target, content) replaces the target element entirely with HTML-encoded text.
  • Update(target, content) replaces the inner content of the target with HTML-encoded text.
  • AppendHtml(target, trustedHtml), PrependHtml(target, trustedHtml), ReplaceHtml(target, trustedHtml), and UpdateHtml(target, trustedHtml) insert trusted HTML without encoding or sanitizing. Use partials/components for app markup when practical, and encode user values before composing trusted fragments.
  • Remove(target) removes the target element.
  • FormError(target, title, message) updates the target with an encoded generated error block and marks the response handled.
  • FormValidationErrors(target, ModelState, title, maxErrors, message) updates the target with a stable MVC validation summary and marks the response handled.
  • Visit(url) emits <turbo-stream action="rw-visit" url="..." visit-action="advance"></turbo-stream> and asks the browser to run a same-origin Turbo visit that advances history.
  • Visit(url, RazorWireVisitAction.Replace) emits the same command with visit-action="replace" so Turbo replaces the current history entry.
  • BuildResult(statusCode) returns the stream and optionally sets the HTTP status code.

Visit(...) accepts relative URLs such as /docs/next, ?tab=done, #summary, ./next, ../next, and same-origin absolute URLs. The server rejects blank URLs and ASCII control characters before rendering; the browser runtime rejects ~/ URLs, protocol-relative URLs, external origins, javascript:, data:, backslash-prefixed values, and malformed input before calling Turbo. Do not retain or replay rw-visit streams through IRazorWireStreamHub; publish a separate idempotent state stream when late subscribers need context.

TagHelpers

rw:island

Wraps content in a <turbo-frame>.

  • id: unique identifier for the island.
  • src: URL to load content from.
  • loading: load strategy such as lazy.
  • permanent: persists the element across Turbo page transitions.
  • swr: enables stale-while-revalidate behavior.
  • client-module: client module path or name to mount for hybrid islands.
  • client-strategy: mount timing such as load, visible, or idle.
  • client-props: JSON payload passed to the client module's mount function.
<rw:island id="sidebar" src="/Reactivity/Sidebar" loading="lazy" permanent="true">
    <p>Loading sidebar...</p>
</rw:island>

form[rw-active]

Enhances a normal form so Turbo handles the submission and optional frame targeting.

  • rw-active="true" enables RazorWire form handling.
  • rw-target sets the target frame when you want to constrain the response.
  • data-rw-form-failure-target points failed-submission UI at a local error container by simple element ID, optionally prefixed with #; selector-like values are ignored.
  • data-rw-form-failure="auto" uses the default fallback UI, manual only dispatches events, and off disables the failure convention for that form.
  • Generated hidden fields __RazorWireForm and, when possible, __RazorWireFormFailureTarget help server-side adapters identify and localize form failures.
<form asp-controller="Reactivity" asp-action="IncrementCounter" method="post" rw-active="true">
    <input type="hidden" name="clientCount" value="0" />
    <button type="submit" aria-label="Increment counter">+</button>
</form>

rw:stream-source

Subscribes the page to a RazorWire stream channel.

  • channel: required channel name.
  • permanent: keeps the stream source alive across Turbo visits.
  • Stream endpoints deny subscriptions by default; configure RazorWireStreamAuthorizationMode.AllowAll only for public/demo channels or provide a custom IRazorWireChannelAuthorizer.
  • Channel names, after URL decoding, may contain only ASCII letters, digits, ., _, -, and :. The tag helper URL-encodes the generated path segment. Direct requests whose channel path segment decodes to spaces, slashes, query/hash characters, control characters, Unicode, or another invalid channel character are rejected with 400; unencoded extra path segments can miss the stream route and return 404, while query strings and fragments are not part of the channel route value.
  • replay: when true, appends ?replay=1 to the stream endpoint so the page receives retained channel messages before live updates. The in-memory hub retains at most 25 messages per replay channel and prunes inactive replay channels when more than 256 replay channels are retained.
  • Listen for razorwire:stream:error on the element when you need client-side diagnostics for failed native EventSource connections. The event detail includes channel, source, state, readyState, and src; it intentionally does not include server response bodies.
<rw:stream-source id="rw-stream-reactivity" channel="reactivity" permanent="true"></rw:stream-source>

Use replay="true" when the source powers resumable UI state, such as a build or harvest progress surface. Leave it off for purely live feeds where old messages would be confusing.

requires-stream

Marks an element as inactive until a named stream is connected.

<button type="submit" requires-stream="reactivity">Send</button>

<time rw-type="local">

Localizes UTC timestamps on the client with the browser's Intl APIs.

  • rw-display: time, date, datetime, or relative.
  • rw-format: short, medium, long, or full.
<time datetime="@Model.Timestamp" rw-type="local" rw-display="relative"></time>

rw:scripts

Injects the client scripts RazorWire needs, including Turbo and the RazorWire assets.

<rw:scripts />

The script tag also carries failed-form runtime configuration derived from RazorWireOptions.Forms; no inline configuration script is required.

Utilities

StringUtils

  • ToSafeId(input, appendHash) sanitizes values for DOM IDs or anchors and can append a deterministic hash for uniqueness.

Client-Side Interop

RazorWire also supports hybrid islands where a server-rendered region mounts a client module:

<rw:island id="interactive-chart"
           client-module="ChartComponent"
           client-strategy="visible"
           client-props='{ "data": [1, 2, 3] }'>
</rw:island>

client-module can be a relative path, root-relative path, same-origin URL, HTTPS URL, or bare import-map specifier. Hosts that prefer logical names can set window.RazorWireIslandModules = { ChartComponent: "/js/chart-component.js" } before hydration; RazorWire resolves the rendered data-rw-module name through that manifest before calling dynamic import(). Direct javascript:, blob:, file:, and unapproved data: module specifiers are rejected.

RazorWire serves /_content/ForgeTrust.RazorWire/razorwire/razorwire.js, razorwire.islands.js, and the package demo assets as normal Razor Class Library static web assets when the host has a static-web-assets manifest. The same files are also embedded into the ForgeTrust.RazorWire assembly and mapped as endpoint fallbacks by RazorWireWebModule, so packaged command-line hosts can serve the runtime even when only compiled assemblies are present.

The runtime and island loader are authored under assets/src and generated into the committed wwwroot/razorwire/*.js package outputs. Public consumers should continue loading the same script URLs or <rw:scripts />; the TypeScript asset pipeline is maintainer-only and does not require application code changes. The docs-only assets/contracts/razorwire-public-contracts.js file preserves the AppSurface Docs JavaScript API harvest, while exampleJsInterop.js stays hand-authored and demo-only. For build commands, diagnostics, the pack-time freshness guard, and the emergency bypass property, see the Runtime Contract Pipeline.

Static Export

RazorWire can generate CDN-ready static output with the installable razorwire .NET tool, or with the short-lived dnx tool execution path. CDN mode is the default: extensionless internal routes such as /about are emitted as files such as about.html, and exporter-managed links, frames, scripts, stylesheets, images, <img> and <source> srcset candidates, CSS url(...) references, and string-form CSS @import "..." dependencies are rewritten to the generated artifact URLs. When the conventional /_appsurface/errors/404 route is available, it emits 404.html through the same validation and rewrite path. Use --mode hybrid when the exported directory will still be served behind infrastructure that resolves application-style extensionless URLs.

Hybrid export can serve RazorWire endpoints through same-origin backend passthrough. Safe RazorWire forms with static anti-forgery tokens are converted to lazy runtime token refresh so the backend wakes only when the user starts interacting with the form or submits it. For split-origin hybrid sites, add --live-origin https://api.example.com; RazorWire export then also rewrites managed live surfaces to that origin without requiring per-form flags: stream sources, live island frames, and RazorWire forms with static anti-forgery tokens. Unsafe anti-forgery fails early with RWEXPORT006 when the form is unmanaged, opts out with rw-antiforgery="off", posts to an external action, or split-origin credentials are omitted. CDN mode fails any anti-forgery surface because a plain static host cannot mint runtime tokens.

CDN export validates the static references it can discover while crawling. Missing frame routes, unsafe query-bearing frame sources, missing internal assets, and managed URLs that cannot be rewritten fail the export with RWEXPORT### diagnostics instead of producing a broken folder. The diagnostics include the HTML element/attribute or CSS token that produced the reference and the normalized path the exporter attempted to prove. The validation boundary is deliberate: app-authored JavaScript fetches, form posts, Server-Sent Events, import maps, and other runtime behavior outside markup/CSS references are not proven static by the exporter.

Parser-backed discovery may find valid references that older exporter versions missed. Treat new CDN validation failures after upgrading as potentially correct: export the missing route or asset, fix path casing, mark authoring-only anchors with data-rw-export-ignore, or choose --mode hybrid when live infrastructure owns the dependency.

Those package-based commands require a published package or an explicit local package source. The package chooser excludes ForgeTrust.RazorWire.Cli until issue #171 lands stable public .NET tool packaging.

For installation, dnx, local-package, and source-run examples, see the RazorWire CLI.

Examples

Product Compatible and additional computed target framework versions.
.NET 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 (1)

Showing the top 1 NuGet packages that depend on ForgeTrust.RazorWire:

Package Downloads
ForgeTrust.AppSurface.Docs

ForgeTrust.AppSurface.Docs package for AppSurface application composition.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0-rc.1 47 5/31/2026
0.1.0-preview.4 58 5/25/2026
0.1.0-preview.3 55 5/20/2026
0.1.0-preview.2 57 5/14/2026