@runic-artifex/views
v0.8.0-preview.1
Published
Shared browser runtime for generated Runic Views clients
Maintainers
Readme
@runic-artifex/views
Shared browser runtime for the TypeScript clients that Runic Views generates from .NET ViewModels. Generated modules import it; applications use it for error handling and for development without a .NET host.
npm install @runic-artifex/views@preview
pnpm add @runic-artifex/views@preview
bun add @runic-artifex/views@previewPreviews are published under the preview dist-tag. Install the version that
matches your Runic SDK packages.
Generated clients
Each generated module exports connect<Name>() for a root ViewModel and
page<Kind>(id) references for presented content. A connected <Name>Client
has a snapshot, subscribe(listener), dispose() and one method per setter,
command and operation. As in any MVVM View, only a property with a public C#
setter gets a set<Property> method; a private set keeps it read-only state:
import { BridgeError } from "@runic-artifex/views";
import { connectCounter } from "./generated/counter.js";
const counter = await connectCounter();
const stop = counter.subscribe(state => render(state.count));
try {
await counter.increment();
} catch (error) {
if (error instanceof BridgeError && error.kind === "disconnected") showOffline();
}
stop();
counter.dispose();subscribe delivers the current state first. After dispose() the client
keeps its last snapshot, and subscribe delivers that state once and returns
a no-op, so framework stores can read a client during teardown. Calls reject
with a BridgeError whose kind is rejected, cancelled, failed,
disconnected, timeout or unavailable; see
Errors and diagnostics.
All generated modules on a page share one runtime instance, even when several
bundles or copies of this package load: a route has one push callback and one
revision, and instanceof BridgeError holds for errors from any copy.
The framework packages @runic-artifex/react, @runic-artifex/vue,
@runic-artifex/svelte and @runic-artifex/angular connect and dispose
clients with the component lifecycle. They are thin bindings over the
controllers in Framework bindings.
Package entries
| Entry | For |
| --- | --- |
| @runic-artifex/views | Applications: errors, diagnostics, controllers, viewport helpers and client types |
| @runic-artifex/views/mock | Development and tests without .NET |
| @runic-artifex/views/generated | Generated modules and hand-written test clients: connectView, viewReferences, defineCollection(s), defineInteractions, bridgeOperations, decodeBridgeValidation |
| @runic-artifex/views/generated/wire | Generated modules, generated *.mock.ts files and hand-written test clients: the wire decoders, imported as import * as bridgeWire |
The generated entries follow the generator and may change between releases.
Application code that uses generated clients imports only the root entry.
Hand-written clients, such as a test client that connects a route without
generated code, may import the generated entries; regenerate or update them
with each SDK upgrade. A generated module passes the interaction, operation and keyed
collection runtimes to connectView only when its ViewModel has them, and
imports the decoders as a namespace, so a bundler leaves out the protocol code
and decoders a View does not use. bun run size in this package prints the
minified and gzip size of the checked-in example clients.
Incremental collections
A .NET ViewModel can mark a read-only collection of DTO rows with
[RunicCollection(nameof(Row.Id))]. Its generated client then receives indexed
add, remove, replace and move frames instead of the whole state, applies them on
top of its current revision, and reads a fresh snapshot if a frame is missing
or invalid, retrying a failed read and keeping frames that arrive meanwhile.
Rows that a frame does not touch keep their object identity, so frameworks can
skip rendering them. Clients still see a complete array in snapshot and
subscribe. If the .NET collection has a null row or a null, empty or
duplicate key, the route keeps its last state and reports the failure through
onBridgeDiagnostic until the keys are valid again.
defineCollection(decode, key)
function defineCollection<T>(decode: (wire: unknown) => T, key: (item: T) => string): BridgeCollectionDefinition;Exported from @runic-artifex/views/generated. Describes one collection field
for the runtime: decode validates and converts a
wire row (throwing for an invalid one), and key returns the row's key, which
must be a nonempty string unique within the field. Generated modules call it for
each [RunicCollection] field, deriving key from the attributed property, and
pass the fields to connectView as collections: defineCollections({ ... }).
Application code does not call it. A hand-written or test client that connects
a collection route itself passes defineCollections({ rows: defineCollection(...) })
in the same way, and its key must match the .NET wire key: the string itself,
a lowercase GUID, or an Int32 in decimal.
collectionViewport(options)
function collectionViewport(options: {
totalCount: number; scrollTop: number; height: number; rowHeight: number; overscan?: number;
}): { start: number; size: number; offset: number; totalSize: number };Computes which rows of a fixed-row-height list to request from .NET, for any web
framework. Pass the total row count published by the ViewModel, the scroll
container's scrollTop and visible height, the row height in pixels and an
optional overscan (default 5 rows before and after the visible range). It
returns the first row to request (start) and how many (size), the pixel
offset of the first returned row, and the totalSize of the scroll spacer.
Negative scrollTop is treated as 0 and the range is clamped to totalCount.
Invalid input (a negative or non-integer count or overscan, a non-finite value, a
negative height or a row height that is not positive) throws a RangeError.
The ViewModel owns the windowed collection; the page sends the requested range through a command and renders the rows it receives:
import { collectionViewport } from "@runic-artifex/views";
import { connectRows } from "./generated/rows.js";
const view = await connectRows();
function requestViewport(scroll: HTMLElement) {
const { start, size } = collectionViewport({
totalCount: view.snapshot.totalCount, scrollTop: scroll.scrollTop,
height: scroll.clientHeight, rowHeight: 32,
});
if (size !== 0) void view.setViewport({ start, size });
}Avoid sending a request when start and size have not changed. The framework
packages wrap this in useCollectionViewport (injectCollectionViewport in
Angular), which follows the container's scroll position and size. The
DynamicData example shows a complete page. The frame
format, fallbacks and recovery rules are specified in
Collection delta frames.
Framework bindings
The framework packages share framework-neutral controllers, so each
binding keeps only its own reactivity glue. Applications normally use the
bindings; a binding for another framework can use the controllers directly.
Each controller has a current value, replaced by a new object on every
change, and subscribe(listener), which returns an unsubscribe function.
createViewController<TClient>({ release? })follows aViewSource: a connector ({ connect() }, such as a generated page reference) that it connects and releases, or a connected client that it only observes.setSource(source)ignores a source with the same client orconnectfunction and does not notify; readcurrentafter calling it.currenthassource,client,state,errorandpending.retry()connects again anddispose()releases the client.isViewClientandviewSourceIdentitytell clients from connectors and compare sources.createCommandController(command)hasrun(...args), which never rejects and resolves toundefinedafter a failure, andcurrentwithpending,errorandfailure. For a command that resolves aBridgeOutcome,failureis the declared failure of the latest run;errorstays the unexpected failure. Starting a run andreset()clear both, and a run superseded by a later run or byreset()sets neither.createCollectionViewportController({ totalCount, rowHeight, overscan? })hasattach(element), which follows the element's scroll position (once per animation frame) and size, andupdate(options).currentis thecollectionViewportrange and changes only when the range or sizes change.createOperationController(start, options?)observes generatedstart<Command>()handles through their publicwait()andoutcome(). Its state covers admission, pending work, terminal status/outcome, declared failure, unexpected error, cancellation feedback and a separatecancelledend state.run(...args)andcancel()never reject. React and VueuseOperation, SvelteuseOperationand AngularinjectOperationexpose the same state.reset()clears feedback while retaining pending work and its cancellation identity;dispose()detaches feedback and settles UI observation promises without cancelling work.createLatestOperationController<TResult, TFailure>()serializes superseding read intent. See Operation feedback and latest selection.
Operation feedback and latest selection
An operation Start receipt proves admission; it does not prove completion. A Cancel
response proves only that the cancellation request was observed. Keep the UI pending
until the public handle's terminal wait() completes. The desktop transport admits
control callbacks while this wait is active, so short status polling is unnecessary.
Render domain progress and recovery from the generated View snapshot: the operation
handle supplies protocol status and outcome, not application progress.
import { createOperationController } from "@runic-artifex/views";
const push = createOperationController((remote: string) => workspace.startPush(remote));
const stop = push.subscribe(() => render(push.current));
void push.run("origin");
await push.cancel(); // pending stays true until terminal observation
// When the UI is released:
stop();
push.dispose();current has pending, admitting, operation, status, outcome, failure,
error, cancelled, cancellationRequested, cancelling, cancellation and
cancelError. pending spans Start admission, terminal wait, any completion barrier
and outcome delivery. A declared failure is an outcome and failure; an unexpected
failure, including a failed or timedOut status, is error. A cancelled status,
whether this UI or .NET requested it, is a normal end: cancelled becomes true,
run() resolves to undefined, and error, outcome and failure stay undefined.
Only a direct operation.outcome() call still rejects with BridgeError("cancelled").
Starting a run and reset() clear cancelled. Cancel failures are cancelError and do not release pending work. A Cancel
requested before a delayed Start receipt is sent after that captured run is admitted.
An absent client may return undefined from start, ending the run without error.
Overlapping runs are observed independently, and only the latest publishes feedback
or receives Cancel. Disable mutation buttons while pending to prevent accidental
duplicate work; a later run never automatically cancels an earlier mutation.
Cancellation of a generated invocation wrapper can finish before accepted model
work or recovery has drained. The controller cannot establish that application
boundary on its own. options.cancel(operation) may call a short model control
command instead of operation.cancel(); it may return void or a typed cancellation
receipt. options.waitForCompletion(operation, terminalStatus) can await the
application's accepted-work drain or command availability after the terminal wait.
The same typed callbacks are available per latest-selection intent. They must capture
the original client/session, and must reject when they cannot establish completion.
If a custom cancellation callback awaits before sending a global control, it must
recheck that captured session immediately before the side effect; replacement cannot
revoke a callback already entered.
Host shutdown still owns and awaits accepted work; UI disposal is not a drain helper.
Read selection often needs a different policy from mutation buttons: remember the newest click, cancel the previous read, and await terminal admission before starting the newest one. A queue can cover several generated read commands:
import { createLatestOperationController } from "@runic-artifex/views";
const selections = createLatestOperationController<void>();
function selectCommit(oid: string) {
const client = currentClient;
const session = currentSession;
if (!client || !session) return;
return selections.run({
start: () => client.startSelectCommit(oid),
cancel: () => client.cancelRead(),
isCurrent: () => currentClient === client && currentSession === session,
});
}
// Explicit Cancel drops queued input and requests cancellation of the current read.
selections.clear();
// After the application establishes replacement admission/session and owns old drain:
selections.clear("replace");Only the newest queued intent survives. Supersession before the Start receipt retains
cancellation until admission; the next Start waits for the previous terminal wait
and optional application completion barrier. A failed Cancel still waits for that
boundary. Any pending cancellation callback also settles before the next Start,
so a delayed global control cannot land on the successor. isCurrent is checked before admission, cancellation and publication,
so departed sessions cannot start queued reads or issue stale global controls.
clear("replace") immediately detaches the old observation, settles its caller and
suppresses delayed cancellation, allowing fresh intent to start without waiting for
an old connection. Use it only after the application establishes fresh admission;
it does not cancel or drain the old work. dispose() likewise settles active/queued
UI callers and consumes late errors without cancelling accepted work.
current.blocked signals that admission or completion is uncertain, with the reason
in current.error. A failed Start whose admission is not explicitly rejected, a
lost terminal observation, unknown, expired, timedOut, or a failed completion
barrier discards queued input and blocks new Starts. Do not retry
blindly. An explicit clear("replace") clears the block only because the application
has established a new boundary; ordinary Cancel does not. Both controllers consume
late promise rejections after detachment and isolate subscriber exceptions through
bridge diagnostics.
Developing without .NET
@runic-artifex/views/mock provides an in-memory Bridge. Install it before the
first connect<Name>() call, for example in a Vite mock mode:
import { installMockBridge } from "@runic-artifex/views/mock";
if (import.meta.env.MODE === "mock") {
const bridge = installMockBridge();
const counter = bridge.view("counter", {
state: { count: 0, canIncrement: true },
routes: { Increment: state => ({ count: Number(state.count) + 1 }) },
});
setInterval(() => counter.update(state => ({ ...state, count: Number(state.count) + 1 })), 5_000);
}A mock View answers Snapshot, Mount, Unmount, Set<Property> (storing
the argument) and Can<Command> (true) by default. Route handlers receive
the state and call arguments; a returned object is merged into the state, a
boolean answers an availability query and a thrown error becomes a failed
reply. State uses the wire representation: for example Int64 values are
strings. bridge.route(name, handler) serves any other route, such as
operation status, and disconnect()/reconnect() exercise the reconnect path.
The wire protocol is specified in the Application Views specification.
Testing
The generator writes a typed mock next to each client: editor.mock.ts
exports mockEditor(bridge, definition). Its state, setter, command, operation
and interaction types come from the generated client, so a renamed or retyped
ViewModel member breaks the test at compile time. The mock encodes typed
values for the wire (for example bigint to an Int64 string) and decodes
call arguments for the handlers.
import { beforeEach, test } from "node:test";
import assert from "node:assert/strict";
import { createMockBridge, installMockBridge, type MockBridge } from "@runic-artifex/views/mock";
import { connectEditor } from "../src/generated/editor.js";
import { mockEditor } from "../src/generated/editor.mock.js";
let bridge: MockBridge;
// Each test installs a new Bridge; generated clients then start from a new page runtime.
beforeEach(() => { bridge = installMockBridge(createMockBridge()); });
test("saving clears the dirty flag", async () => {
mockEditor(bridge, {
state: { title: "Groceries", body: "", isDirty: true, savedMessage: "" },
commands: {
save: async state => {
await bridge.sleep(250); // virtual time
return { isDirty: false, savedMessage: `Saved ${state.title}` };
},
},
});
const editor = await connectEditor();
const saved = editor.save(); // declares SaveFailure, so it resolves a BridgeOutcome
await bridge.advance(250);
const outcome = await saved;
assert.equal(outcome.ok && outcome.value.savedMessage, "Saved Groceries");
});A definition takes:
state: the client state. Command availability (can<Command>) and execution (is<Command>Executing) default to available and idle, and content is a reference{ kind, id }: use another mock'sreference.id: presents the mock as content on routecontent<id>instead of the root route.setters,commands,canExecute: handlers by client method. A handler receives the decoded state and arguments and returns state changes; a thrown error becomes the client'sBridgeError, with itskindwhen it has one. A setter's value is applied first; checked writes (write<Property>) use the same handler and answerconflictfor a stale baseline.operations: runsstart<Command>(). Without a handler the command handler runs and the operation succeeds."manual"keeps each operation running until the test callssucceed(result),fail(message),failWith(failure)(the declared failure),cancel()or, for a stream,emit(...items)onmock.operations.<command>[n]. A command or operation handler that throwsmockFailure(failure)(from@runic-artifex/views/mock) fails with its declared failure, asthrow new RunicFailureException(...)does in .NET. Started operations setis<Command>Executing. Cancellation is cooperative, as in .NET: the client'scancel()aborts the operation'ssignal, and the operation ends when its handler stops (a throw after the request ends itcancelled) or the test settles it. A request id names one operation: a repeated start returns it, and reuse for another command or input is rejected.
The returned mock has state, update(changes) (pushes a full state like a
.NET publication), calls, failNext(method, { kind, message, detail }) or
failNext(method, { kind: "domain-failed", failure }) for a method's declared failure,
pushFailure({ message }) (a failure notice: the client keeps its last state
and reports the error), collections.<field> with add, remove, replace
and move (each pushes a delta frame; batch(edit) combines edits into one),
and interactions.<name>.request(input), which resolves with the mounted
client handler's answer, or unhandled when none is registered.
createMockBridge({ scheduling: "manual" }) queues every reply and pushed
frame until the test calls flush(), flushUntil(promise) or advance(ms),
so the test decides when the client observes each one. sleep(ms) resolves in
virtual time, which only advance moves, in both scheduling modes.
failNext(route, { kind: "transport" }) rejects the call itself, like a
dropped connection, and disconnect()/reconnect() exercise the reconnect
path.
The mock follows the .NET protocol where a client can observe it:
- Collection frames match the .NET producer for the same edits (checked against the shared collection delta fixtures). Each change advances the revision, and a frame of more than 4,096 changes is sent as a full state.
- With manual scheduling, a full state or failure notice replaces the frames of its route that the client has not received yet, as .NET delivery does.
- A checked write retried with the same request id returns its first receipt, and
reusing the id for another write is a
conflict. A setter handler that throws an error withkind: "committed-with-error"keeps the value and returns that receipt. - Only snapshot replies carry
protocol, and a declared failure repliesdomain-failedwithfailureand the state after the call. disconnect()ends the mounted presentations, and a laterMountin their browser session answersdisconnected, as .NET does when a CS-WebUI reconnect keeps the connection key. Afterreconnect()the client mounts its Views again in a new browser session.
It does not model the .NET delivery queue (its 64-frame and 1 MiB bounds and the
recovery they trigger) or the producer's key checks: an edit that would leave an
empty or duplicate key throws a RangeError in the test instead of sending a
failure notice. Use pushFailure and push(frame) for those client paths.
The CommunityToolkit Notes example
tests its frontend this way with bun test; any runner that loads ES modules,
such as Vitest, works the same.
Declared failures
A .NET command that declares its failure type ([RunicFailure(typeof(SaveFailure))])
resolves a BridgeOutcome instead of rejecting when it fails as declared:
{ ok: true, value } or { ok: false, failure }. Unexpected failures still
reject with BridgeError. Handle a $case union with matchCase, which
requires a handler for every case, or with a switch and a never check:
import { matchCase } from "@runic-artifex/views";
const outcome = await editor.save();
if (!outcome.ok) {
showError(matchCase(outcome.failure, {
titleRequired: () => "A note needs a title.",
titleTaken: failure => `"${failure.existingTitle}" already exists.`,
}));
return;
}
navigate();Declaring a failure changes control flow for callers that ignore the result:
await save(); navigate() used to stop at the rejection and now continues after
a declared failure. Check outcome.ok at every call site.
An operation's outcome(options?) waits like wait() and resolves the same
way; wait() and completion still return the status, a union discriminated by
kind (BridgeOperationStatus<TResult, TFailure>):
| Terminal status | outcome() |
| --- | --- |
| succeeded | resolves { ok: true, value: result }; a void or stream operation resolves value: undefined |
| succeeded with delivery | a value operation rejects BridgeError("failed") with the status as cause; a void or stream operation resolves |
| domain-failed | resolves { ok: false, failure }; a failure that could not be delivered or decoded rejects BridgeError("failed") |
| failed | rejects BridgeError("failed") with detail in development |
| cancelled | rejects BridgeError("cancelled") |
| timedOut | rejects BridgeError("timeout") with the status, including cancellation, as cause |
| expired, unknown | rejects BridgeOperationUncertainError: the outcome is unknown, so do not retry blindly |
bridgeSuccess(value), bridgeFailure(failure) and isBridgeOutcome(value)
create and recognize outcomes, for example in tests and mocks. An outcome is
recognized by a non-enumerable brand, so a spread or JSON copy is plain data.
The framework command helpers (useCommand, injectCommand and
createCommandController) keep the declared failure of the latest run as
failure, apart from error. In @runic-artifex/views-effect, a declared
failure is the ViewDomainFailure error, and catchCase handles its cases. In
tests, a mock handler throws mockFailure(failure), and a generated mock's
failNext(method, { kind: "domain-failed", failure }) and failWith(failure)
take the method's typed failure.
Errors and diagnostics
A BridgeError names what failed and why:
kindisrejected,cancelled,failed,disconnected,timeout(a Bridge is installed but did not connect in time) orunavailable(no host installedwindow.__runicBridge, for example a frontend opened from a plain Vite server; the message says how to fix it). A reply kind the client does not know, including a declared failure of a command whose client does not declare it, isfailed.- A command that replaces or leaves its own page in a navigation region ran,
so its call resolves, with the last state the client accepted, although .NET
released the page's View before the reply. A later call of the released View,
including its snapshot read, rejects with
disconnected. routeis the Bridge route that failed, such ascounterIncrement.causeis the underlying error, such as the transport rejection or the decoder error of an invalid state.detailis{type, message, stack}of the .NET exception. .NET sends it only in development: when the host environment isDevelopment, whichdotnet runic devsets, or when the application setsBridgeDiagnostics.IncludeFailureDetail = true. Production replies carry only the bounded message, such as"Save failed.".
A generated connect<Name>() waits up to five seconds (5000 ms) for the host
Bridge, then rejects with a timeout BridgeError. Pass timeout to wait
longer, for example while a slow machine starts the .NET host:
const client = await connectWorkspace({ timeout: 30_000 });A framework hook takes the same option through a connect function declared
once, outside the component: const connectSlowly = () => connectWorkspace({ timeout: 30_000 });
then useView({ connect: connectSlowly }).
waitForBridge({ timeout }) (or waitForBridge(30_000)) waits for the Bridge
without connecting a client.
An operation's wait({ timeout }) bounds how long the client waits. When the
timeout passes, the client asks .NET to cancel the operation and resolves to a
status with kind: "timedOut" and cancellation set to .NET's answer
(cancellation-requested, unknown, expired) or unobserved when the
request failed. An operation that finished just before the cancellation keeps
its real terminal status. timedOut is decided by the client; completion
still reports the status .NET settles on. The cancellation applies to the
operation itself, so every other observer of it, including another tab or a
later recover<Command>WithRequestId, sees it cancelled. Use a timeout only
when the caller owns the operation.
const save = await editor.startSave();
const status = await save.wait({ timeout: 10_000 });
if (status.kind === "timedOut") showRetry();onBridgeDiagnostic(listener) observes runtime failures: failed calls, a
missing or unconnected Bridge, operation timeouts, and errors the runtime caught
from listeners, invalid pushes and reconnect work (those are also passed to
reportError), and a .NET host that speaks another Views protocol version
(code: "protocol", once per Bridge). Each failure is delivered once. @runic-artifex/vite-plugin-runic
forwards them to its DevTools dock during development.
