ForgeTrust.RazorWire
0.1.0-rc.1
dotnet add package ForgeTrust.RazorWire --version 0.1.0-rc.1
NuGet\Install-Package ForgeTrust.RazorWire -Version 0.1.0-rc.1
<PackageReference Include="ForgeTrust.RazorWire" Version="0.1.0-rc.1" />
<PackageVersion Include="ForgeTrust.RazorWire" Version="0.1.0-rc.1" />
<PackageReference Include="ForgeTrust.RazorWire" />
paket add ForgeTrust.RazorWire --version 0.1.0-rc.1
#r "nuget: ForgeTrust.RazorWire, 0.1.0-rc.1"
#:package ForgeTrust.RazorWire@0.1.0-rc.1
#addin nuget:?package=ForgeTrust.RazorWire&version=0.1.0-rc.1&prerelease
#tool nuget:?package=ForgeTrust.RazorWire&version=0.1.0-rc.1&prerelease
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:
- Clone this repository and use the .NET 10 SDK.
- Run the MVC sample:
dotnet run --project examples/razorwire-mvc/RazorWireWebExample.csproj
- 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 andpermanent="true"persistence across page transitions. - Push live updates to connected clients with
IRazorWireStreamHubandrw: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 fitMaxChannelNameLength. - Invalid or overlong channels return
400before custom authorizers run. - Authorization denials return
403and do not consume admission capacity. - Capacity denials return
429before SSE headers are written and beforeIRazorWireStreamHub.Subscribe(...)is called. MaxLiveChannels,MaxLiveSubscriptions, andMaxLiveSubscriptionsPerChannelare 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><b>Ada</b></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.cshtmlfiles 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 transportscontentas-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 toDenyAll.MaxChannelNameLength: defaults to128decoded characters.MaxLiveChannels: defaults to64live channel names per process.MaxLiveSubscriptions: defaults to256live SSE requests per process.MaxLiveSubscriptionsPerChannel: defaults to32live 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
DenyAllRazorWireChannelAuthorizeris selected by default throughRazorWireOptions.Streams.AuthorizationMode = RazorWireStreamAuthorizationMode.DenyAll. AllowAllRazorWireChannelAuthorizeris selected byRazorWireOptions.Streams.AuthorizationMode = RazorWireStreamAuthorizationMode.AllowAlland 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 returns403unless a customIRazorWireChannelAuthorizeris 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), andUpdateHtml(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 withvisit-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 aslazy.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 asload,visible, oridle.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-targetsets the target frame when you want to constrain the response.data-rw-form-failure-targetpoints 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,manualonly dispatches events, andoffdisables the failure convention for that form.- Generated hidden fields
__RazorWireFormand, when possible,__RazorWireFormFailureTargethelp 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.AllowAllonly for public/demo channels or provide a customIRazorWireChannelAuthorizer. - 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 with400; unencoded extra path segments can miss the stream route and return404, while query strings and fragments are not part of the channel route value. replay: whentrue, appends?replay=1to 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:erroron the element when you need client-side diagnostics for failed nativeEventSourceconnections. The event detail includeschannel,source,state,readyState, andsrc; 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, orrelative.rw-format:short,medium,long, orfull.
<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 | Versions 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. |
-
net10.0
- CliWrap (>= 3.10.1)
- ForgeTrust.AppSurface.Web (>= 0.1.0-rc.1)
- Microsoft.AspNetCore.Mvc.Razor.Extensions (>= 6.0.36)
- Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation (>= 10.0.2)
- Microsoft.CodeAnalysis.Analyzers (>= 3.11.0)
- Microsoft.CodeAnalysis.CSharp (>= 5.0.0)
- Microsoft.CodeAnalysis.Razor (>= 6.0.36)
- Microsoft.Extensions.DependencyModel (>= 10.0.2)
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 |