Frontend Plugins
Build reusable browser integrations with managed element behaviors, custom triggers, independent payload sources, and registered response commands.
This guide covers consuming a plugin, authoring its public contract, shipping it with a Razor Class Library, and verifying its full browser/server lifecycle.
Choose the extension points
A plugin can register any combination of four capabilities. Use the pieces your integration needs; the server continues to own the HTML returned by content invocations.
| Capability | Use it for | Public registration |
|---|---|---|
| Behavior | Initialize, update, and dispose an editor, picker, chart, or existing browser widget on matching elements. | api.behaviors.register |
| Trigger | Turn a DOM event or an external SDK callback into a normal Heimdall invocation. | api.triggers.register |
| Payload source | Read the data an invocation needs, independently of which trigger fired. | api.payloads.register |
| Command | Receive structured server arguments in an explicitly registered browser handler. | api.commands.register |
Examples include rich text editing, autocomplete, barcode readers, file-selection SDKs, charts, maps, accessibility helpers, and adapters around existing jQuery widgets. The registry owns installation and lifecycle; application services still handle validation, authorization, persistence, and HTML rendering.
These are application runtime plugins. AI agent skills and marketplace packaging have their own guide. Server-side library discovery is also a separate registration surface.
Load and install a plugin
The consuming application loads the runtime and plugin assets, supplies options, and calls Heimdall.use. Keep the core runtime synchronous in the document head before the registration module so installation precedes DOMContentLoaded boot.
<link rel="stylesheet" href="/_content/Company.Editor/css/editor.css">
<script src="/_content/HeimdallFramework.Web/heimdall-bundle.min.js"></script>
<script type="module" src="/js/install-plugins.js"></script>// wwwroot/js/install-plugins.js in the application
import editorPlugin from "/_content/Company.Editor/js/editor-plugin.js";
if (Heimdall.pluginApiVersion !== 1)
throw new Error("This integration requires Heimdall plugin API v1.");
Heimdall.use(editorPlugin, { commitKey: "Enter" });Availability: this page documents plugin API v1 in the current framework source. Use matching Server, Rendering, and Web builds that contain these APIs. A previously installed runtime can expose Heimdall.invoke while still lacking the plugin registry; check pluginApiVersion when integrating an older application.
- An RCL serves wwwroot assets through /_content/{PackageId}/...; an application can instead serve a module from its own wwwroot.
- A bundler can import the same plugin object. Classic script distributions can expose their object on a package-owned global, then install it explicitly.
- Keep one application Heimdall runtime. A plugin package uses that runtime and does not bundle a second instance.
- NuGet installation, AddCompanyEditor, and invocation assembly scanning do not load JavaScript/CSS or call Heimdall.use.
- Heimdall.use does not fetch scripts, styles, or dependencies. The application controls loading, CSP, dependency order, and cache/version deployment.
- Late installation initializes existing matching elements, but skipped commands are not replayed. Install before initial load invocations need a plugin payload or command.
Complete example: editor module to usable feature
This small editor supports Ctrl+Enter and a normal Save button through the same payload reader. The server trims the submitted text, returns a status fragment, and sends a registered command to update the textarea. Reset is a static invocation; Save uses a scoped instance with options.
Library layout: EditorRegistration.cs, EditorPanel.cs, wwwroot/js/editor-plugin.js, and wwwroot/css/editor.css. Use a Razor Class Library with PackageId Company.Editor and a reference to the matching HeimdallFramework.Server package. Server references HeimdallFramework.Rendering transitively.
Company.Editor/
EditorRegistration.cs
EditorPanel.cs
wwwroot/
js/editor-plugin.js
css/editor.css
ConsumingApp/
Program.cs
wwwroot/js/install-plugins.js// Company.Editor/wwwroot/js/editor-plugin.js
export default {
id: "company.editor",
version: "1.0.0",
pluginApiVersion: 1,
install(api, options = {}) {
const commitKey = options.commitKey ?? "Enter";
if (typeof commitKey !== "string" || !commitKey)
throw new TypeError("commitKey must be a non-empty string.");
api.behaviors.register({
name: "keyboard",
selector: "textarea[data-company-editor]",
mount(element, { signal }) {
element.addEventListener("keydown", event => {
if (!(event instanceof KeyboardEvent) ||
event.key !== commitKey || !event.ctrlKey) return;
event.preventDefault();
element.dispatchEvent(new CustomEvent(
"company-editor-commit", { bubbles: true }));
}, { signal });
}
});
api.triggers.register({
name: "commit",
events: ["company-editor-commit"]
});
api.payloads.register({
name: "document",
read({ element }) {
const editor = element instanceof HTMLTextAreaElement
? element
: element.closest("[data-company-editor-host]")
?.querySelector("textarea[data-company-editor]");
if (!(editor instanceof HTMLTextAreaElement))
throw new TypeError("The editor payload source is missing.");
return { text: editor.value };
}
});
api.commands.register({
name: "set-text",
/** @returns {args is { text: string }} */
validate: args => typeof args === "object" && args !== null &&
"text" in args && typeof args.text === "string",
handle({ element, args, signal }) {
if (!(element instanceof HTMLTextAreaElement))
throw new TypeError("An editor command needs a textarea.");
if (!signal.aborted) element.value = args.text;
}
});
}
};The behavior owns its keyboard listener through the mount signal. The trigger declares which event starts an invocation. The reader also accepts a button inside the editor host, so the built-in click path shares the same data contract. The command validates its JSON arguments before touching the textarea.
// Company.Editor/EditorRegistration.cs
using Heimdall.Server;
using Microsoft.Extensions.DependencyInjection;
namespace Company.Editor;
public sealed class CompanyEditorMarker;
public sealed class EditorOptions
{
public string SavedLabel { get; set; } = "Saved";
}
public static class EditorRegistration
{
public static IServiceCollection AddCompanyEditor(
this IServiceCollection services,
Action<EditorOptions>? configure = null)
{
services.AddHeimdallInvocationAssembly<CompanyEditorMarker>();
services.AddScoped<EditorPanel.Invocations>();
services.AddOptions<EditorOptions>();
if (configure is not null) services.Configure(configure);
return services;
}
}// Company.Editor/EditorPanel.cs
using Heimdall.Server;
using Heimdall.Server.Rendering;
using Microsoft.AspNetCore.Html;
using Microsoft.Extensions.Options;
namespace Company.Editor;
public sealed class EditorDocument
{
public string? Text { get; set; }
}
public static class EditorPanel
{
public static IHtmlContent Render()
=> FluentHtml.Section(panel => panel
.Attr("data-company-editor-host", "")
.Label(label => label.For("company-editor").Text("Document"))
.TextArea(editor =>
{
editor.Id("company-editor")
.Attr("data-company-editor", "true")
.Class("company-editor-input");
editor.Heimdall()
.OnPluginTrigger("company.editor.commit", "company.editor.save")
.PayloadFromPlugin("company.editor.document")
.Target("#editor-status")
.SyncReplace("company-editor");
})
.Button(button =>
{
button.Type("button").Text("Save");
button.Heimdall().OnClick("company.editor.save")
.PayloadFromPlugin("company.editor.document")
.Target("#editor-status")
.SyncReplace("company-editor");
})
.Button(button =>
{
button.Type("button").Text("Reset");
button.Heimdall().OnClick("company.editor.reset")
.PayloadEmptyObject().Target("#editor-status")
.SyncReplace("company-editor");
})
.Div(status => status.Id("editor-status")
.Attr("role", "status").Text("No changes saved")));
public sealed class Invocations(IOptions<EditorOptions> options)
{
[ContentInvocation("company.editor.save")]
public IHtmlContent Save(EditorDocument document)
{
var text = (document.Text ?? "").Trim();
return FluentHtml.Fragment(fragment =>
{
fragment.Add(FluentHtml.Span(status => status
.Text($"{options.Value.SavedLabel}: {text.Length} characters")));
fragment.Heimdall().PluginCommand(
"company.editor.set-text", new { Text = text }, "#company-editor");
});
}
}
public static class StaticInvocations
{
[ContentInvocation("company.editor.reset")]
public static IHtmlContent Reset()
=> FluentHtml.Fragment(fragment =>
{
fragment.Add(FluentHtml.Span(status => status.Text("Editor reset")));
fragment.Heimdall().PluginCommand(
"company.editor.set-text", new { Text = "" }, "#company-editor");
});
}
}The host calls the package extension, configures its own Heimdall services, maps a page, and loads the module as shown above. This example normalizes input and reports its length; add your persistence service inside Save when implementing a stored document.
// Consuming application: Program.cs
using Company.Editor;
using Heimdall.Server;
using Heimdall.Server.Rendering;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAntiforgery();
builder.Services.AddCompanyEditor(options =>
options.SavedLabel = "Document saved");
builder.Services.AddHeimdall();
var app = builder.Build();
app.UseAntiforgery();
app.MapStaticAssets();
app.UseStaticFiles();
app.UseHeimdall();
app.MapHeimdallPage("/editor", () => FluentHtml.Fragment(fragment => fragment
.Raw("<!DOCTYPE html>")
.HtmlTag(html => html.Lang("en")
.Head(head => head
.Meta(meta => meta.Attr("charset", "utf-8"))
.Meta(meta => meta.Name("viewport")
.ContentAttr("width=device-width, initial-scale=1"))
.Title(title => title.Text("Company editor"))
.Link(link => link.Attr("rel", "stylesheet")
.Href("/_content/Company.Editor/css/editor.css"))
.Script(script => script
.Src("/_content/HeimdallFramework.Web/heimdall-bundle.min.js"))
.Script(script => script.Type("module")
.Src("/js/install-plugins.js")))
.Body(body => body.Add(EditorPanel.Render())))));
app.Run();/* Company.Editor/wwwroot/css/editor.css */
.company-editor-input {
display: block;
width: 100%;
min-height: 8rem;
margin-block: 0.5rem;
}| When you interact | What happens |
|---|---|
| Ctrl+Enter | Managed keyboard behavior emits company-editor-commit; the custom trigger invokes company.editor.save. |
| Save button | Built-in click invokes the same method and reads the same plugin payload. |
| Server response | The status HTML swaps into #editor-status, then company.editor.set-text applies the normalized text. |
| Reset button | A static method returns status HTML and a command; both methods are discovered from the same library assembly. |
| Editor removed | The behavior signal aborts, releasing its event listener. A later replacement gets its own active mount. |
The example renders one editor with fixed IDs. A multi-instance component should generate stable per-instance host/target IDs, keep widget instances in a WeakMap, and resolve payloads within that instance's host. Plugin and extension names identify capabilities; DOM IDs identify individual rendered instances.
Plugin identity, versions, options, and namespaces
A plugin is an object with a stable identity and a synchronous install function. Its package version and the frontend SDK version describe different contracts.
export default {
id: "company.editor",
version: "1.0.0",
pluginApiVersion: 1,
requires: ["company.foundation"],
install(api, options) {
// Register capabilities and cleanup synchronously.
}
};| Member | Required | Contract |
|---|---|---|
| id | Yes | Lowercase dotted or hyphenated alphanumeric segments. The heimdall namespace is reserved. |
| pluginApiVersion | Yes | Use 1 for this SDK contract. It is independent of the HTTP protocol's Heimdall.apiVersion. |
| version | No | Diagnostic package metadata. The runtime does not use it as a semantic-version compatibility resolver. |
| requires | No | Plugin IDs that must already be installed. The host chooses package versions and installation order. |
| install(api, options) | Yes | Registers synchronously; returns undefined or one plugin-wide cleanup function. Promises are rejected. |
Local extension names are qualified automatically. Registering set-text in company.editor creates company.editor.set-text. Different capability registries may use the same local name; duplicate names within one registry and conflicting trigger attributes are rejected.
Heimdall passes the host's options to the plugin. Validate them and apply your own defaults. They are not implicitly merged into Heimdall.config, cloned into a framework options model, or interpreted by the server. Server EditorOptions and JavaScript commitKey options remain separate contracts.
Installation, dependencies, rollback, and removal
Installation stages registrations and commits them only after install succeeds. This keeps a failed registration from leaving a partially installed registry.
const options = { commitKey: "Enter" };
Heimdall.use(editorPlugin, options);
Heimdall.use(editorPlugin, options); // Exact references: no-op.
console.table(Heimdall.plugins.list());
const removed = Heimdall.plugins.remove("company.editor");- Heimdall.use returns Heimdall for chaining. Repeating the identical plugin object and options reference is idempotent.
- A different plugin object or options reference under an installed ID throws instead of silently replacing a live plugin.
- Missing dependencies, unsupported plugin API versions, duplicate registrations, invalid descriptors, and an async install fail installation.
- Rollback aborts api.signal, invokes registered plugin cleanup, and discards staged registry entries. Arbitrary external side effects still require your cleanup.
- Registrations and api.onDispose close when install returns. Async setup belongs to a behavior's mount rather than a later register call.
- Removal aborts the plugin scope, disposes active mounts and subscriptions, and unregisters capabilities. Unknown IDs return false.
- Removing a plugin that an installed dependent still requires throws. Remove dependents first, then their dependency.
- The same plugin can be installed again after removal; it starts a fresh active lifetime.
install(api, options) {
window.addEventListener("resize", onResize, { signal: api.signal });
const timer = setInterval(checkExternalState, 5000);
api.onDispose(() => clearInterval(timer));
// A returned function is an additional plugin-wide cleanup.
return () => releaseSharedResources();
}| Installation API | Purpose |
|---|---|
| api.pluginId / api.pluginApiVersion | Identify the owning plugin and SDK contract. |
| api.signal | Aborts when installation rolls back or the plugin is removed. |
| api.*.register(definition) | Register a behavior, trigger, payload source, or command; returns the qualified name. |
| api.onDispose(cleanup) | Register plugin-wide synchronous cleanup while install is active. |
| api.invoke(id, payload, options) | Use the normal request pipeline after installation commits; combines plugin lifetime and options.signal cancellation. |
Prefer declarative triggers for rendered interactions. api.invoke is available for narrow programmatic integrations after installation commits, and rejects after plugin removal. It supports the normal invocation options, antiforgery, headers, synchronization, and results.
Managed behaviors and async readiness
Behaviors connect a selector to an element lifetime. Use them to initialize third-party widgets and own the work that must stop when those elements leave the document.
api.behaviors.register({
name: "widget",
selector: "[data-company-widget]",
observedAttributes: ["data-widget-options"],
async mount(element, { signal }) {
// createWidget is supplied by your widget integration.
const widget = await createWidget(element, { signal });
return {
update() {
widget.configure(element.getAttribute("data-widget-options"));
},
dispose() { widget.destroy(); }
};
}
});| Descriptor or result | Meaning |
|---|---|
| name | Local capability name; qualified by the owning plugin. |
| selector | Mount on the matching root and every matching descendant. Prefer explicit marker attributes. |
| observedAttributes | Attributes whose changed values request update or remount. |
| mount(element, context) | Context includes element, pluginId, the plugin options, and this mount's signal. |
| Return undefined | Use signal-owned listeners or other resources that need no separate cleanup. |
| Return cleanup function | Called once when this active mount is disposed. |
| Return { update?, dispose? } | Synchronous update for observed changes, and optional cleanup. |
| Return a promise of these results | Async mount participates in bounded readiness. install itself stays synchronous. |
Heimdall waits for behavior readiness before initial load invocations consume plugin payloads and before a targeted command runs against its relevant behavior subtree. Keep data readers synchronous by completing asynchronous preparation in mount.
An observed change calls update when available; an instance without update is disposed and remounted. Update is synchronous. A failed or timed-out mount aborts its signal and reports a diagnostic. Failed mounts are not retried on every unrelated DOM mutation; change observed options or remove/restore the marker to request another mount.
Cleanup that arrives from an async mount after removal still runs. Pass the signal into cooperative widget APIs and check it around your own awaits. An aborted signal cannot undo effects that third-party JavaScript already performed.
DOM moves, swaps, observation, and disposal
A behavior instance follows an active element match. The runtime preserves existing instances when the same DOM element stays active and disposes ownership when it leaves.
| Operation | Behavior lifetime |
|---|---|
| Initial document or boot(root) | Mount the matching root and matching descendants once per active match. |
| Repeated boot or a DOM move | Preserve the active instance; do not attach another copy of its handlers. |
| Append/prepend | Keep existing instances and initialize every inserted root. |
| Inner swap | Dispose affected descendants before replacing them; preserve the target's own active instance if it still matches. |
| Outer swap | Dispose the replaced target and its affected subtree, then initialize the inserted roots. |
| Cancelled swap-before | Do not dispose the current DOM merely because a replacement was proposed. |
| Marker removed or selector no longer matches | Dispose that active behavior instance. |
| External removal with observation enabled | Reconcile the final document state asynchronously. |
| Plugin removed | Abort its signals and dispose all its active mounts/subscriptions. |
// If your application disables normal DOM observation:
Heimdall.dispose(container); // Before external removal.
container.remove();
host.append(newContent);
Heimdall.boot(host); // After external insertion.Heimdall's own inserted roots initialize even when observeDom is false. Its automatic observer covers the main document; shadow roots and other documents require their own integration. Choose selectors whose membership reflects ownership, rather than transient hover/focus state or unrelated descendants.
Custom DOM triggers
A custom trigger declares an event-to-invocation mapping and enters the same request pipeline as a built-in click, input, or submit.
api.triggers.register({
name: "commit",
events: ["company-editor-commit"],
preventDefault: true,
debounceMs: 150,
filter: ({ event }) => event !== null
});| Descriptor | Contract |
|---|---|
| name | Local name, qualified to company.editor.commit. |
| event / events | One native event name or an array of individual event names. |
| attribute | Optional explicit trigger attribute. Built-in attributes cannot be overridden. |
| filter({ element, event }) | Optional synchronous eligibility check. |
| preventDefault / debounceMs | Plugin defaults; normal markup modifiers participate in the shared trigger rules. |
| subscribe | Use instead of native event descriptors for an external callback/event source. |
<textarea data-company-editor
heimdall-content-company-editor-commit="company.editor.save"
heimdall-payload-from="plugin:company.editor.document"
heimdall-content-target="#editor-status"
heimdall-sync="replace"
heimdall-sync-group="company-editor"></textarea>
<div id="editor-status"></div>Default attributes replace dots in the qualified name with hyphens. If you configure an explicit custom attribute, emit that exact attribute with an ordinary HTML attribute helper; OnPluginTrigger emits the default mapping.
Custom triggers share scope/self/closest resolution, ignore boundaries, prevent-default, debounce, disabled checks, request sync, headers, antiforgery, payload selection, targeting, swaps, and lifecycle events. Ignore lists use the qualified trigger name or *. Multiple event types may be declared on one rendered element. Trigger adapters do not need a separate fetch implementation.
SDK subscriptions and explicit emissions
Use subscribe when an integration has a callback API rather than a native DOM event. Its cleanup owns that subscription; a removed subscription cannot start more requests.
api.triggers.register({
name: "scan",
subscribe({ element, emit, signal }) {
// scanner is provided by your integration.
const unsubscribe = scanner.subscribe(value => {
if (!signal.aborted)
void emit(null, { payload: { code: value } });
});
return unsubscribe;
}
});| Emission | Meaning |
|---|---|
| emit(event) | Use the element's configured payload source and normal event context. |
| emit(null, { payload }) | Submit an explicit JSON/FormData snapshot through the same coordinator. |
| Returned promise | Resolves after invocation processing; cancelled/superseded debounced work can resolve null. |
| Returned cleanup | Synchronous unsubscribe function called once for the active subscription. |
Use the supplied signal for listeners or cooperative SDK work. The returned cleanup should release external callbacks, timers, or subscriptions even if the SDK does not accept AbortSignal. Handle any additional promise rejection from your own integration code; use Heimdall diagnostics for managed trigger failures.
Independent payload readers and queue snapshots
A payload source belongs to a named data-reading capability, so the same reader works with a custom trigger, built-in click, submit, or load.
api.payloads.register({
name: "document",
timing: "trigger", // Default; or "execution".
read({ element, options }) {
return { text: readPreparedEditorText(element) };
}
});read receives the source element and installation options. It returns JSON-serializable data or FormData synchronously. Promise-returning readers are rejected; perform async setup in a behavior. The example helper readPreparedEditorText represents your integration's synchronous reader.
| Situation | Snapshot rule |
|---|---|
| timing: trigger | Read after debounce when the trigger starts; preserve the submitted data while queued. |
| timing: execution | Re-read when queued execution begins. The source element and registration must still be available. |
| Explicit request-config payload | The application override remains authoritative. |
| emit with explicit payload | Use the submitted explicit snapshot. |
| Antiforgery retry | Reuse the attempt's payload, rather than reading newer widget values. |
| Missing/failed reader | Report the failure and do not send an empty replacement payload. |
| Removed execution binding | Cancel with payload-source-unavailable. |
// C#: the same reader can be attached to a built-in trigger.
button.Heimdall().OnClick("company.editor.save")
.PayloadFromPlugin("company.editor.document")
.Target("#editor-status");Keep payloads small and validate them server-side. A plugin reader does not change the server's one-payload binding rule or turn browser widget state into trusted domain state.
Registered response command handlers
A command is a qualified name plus JSON arguments and an optional DOM target. The browser resolves only a handler already registered by a loaded plugin.
api.commands.register({
name: "set-text",
/** @returns {args is { text: string }} */
validate: args => typeof args === "object" && args !== null &&
"text" in args && typeof args.text === "string",
async handle({ element, args, signal }) {
if (!(element instanceof HTMLTextAreaElement))
throw new TypeError("An editor target is required.");
await prepareEditor(element, signal);
if (signal.aborted) return;
element.value = args.text;
}
});prepareEditor is integration-owned asynchronous work. Check signal after awaits before performing effects. A command's validate function must return true to accept its arguments; throwing, false, or malformed JSON skips that command and reports a diagnostic.
| Handler context | Meaning |
|---|---|
| element | Resolved target element, or null when no selector was supplied. |
| args | Parsed JSON arguments after optional validation. |
| signal | Command lifetime; observes request/SSE/plugin cancellation and timeout. |
| pluginId | Owning plugin ID. |
| invocationId | Content invocation ID when available; null for a stream message without one. |
| sourceElement | The triggering element when available. |
| origin | invocation or sse. |
| timing | before or after. |
No target means one call with element null. A target selector uses the first matching element; allTargets applies to each match. Selectors resolve when the command executes, so an after command can target newly inserted HTML. Missing, invalid, or disconnected targets are reported and skipped. Related target behavior readiness completes before the handler runs.
C# helpers and wire format
Use encoded helpers so command arguments remain data and HTML text/attribute encoding is correct. JSON argument property names use web defaults.
using Heimdall.Server.Rendering;
fragment.Heimdall().PluginCommand(
name: "company.editor.set-text",
args: new { Text = "Saved content" },
targetSelector: "#company-editor",
timing: HeimdallHtml.PluginCommandTiming.After,
allTargets: false);
// Static form; suitable for an invocation or Bifrost payload.
var command = HeimdallHtml.PluginCommand(
"company.editor.set-text", new { Text = "Saved content" },
"#company-editor");
// The other markup helpers:
HeimdallHtml.OnPluginTrigger("company.editor.commit", "company.editor.save");
HeimdallHtml.PayloadFromPlugin("company.editor.document");<heimdall-command
name="company.editor.set-text"
args='{"text":"Saved content"}'
target="#company-editor"
timing="after"></heimdall-command>Omit target for a global command; use allTargets: true for every selector match. C# rejects allTargets without a selector and rejects blank selectors. Use PluginCommandTiming.Before for the before phase. Fully qualified lowercase extension names are required.
This response directive is separate from browser-native command/commandfor attributes and from JsInvokeVoidBefore/After. Plugin commands provide registered handlers with argument validation, awaited completion, lifetime signals, and managed target readiness. Existing JavaScript void directives retain their original completion semantics.
Response order, aborts, redirects, and cancellation
A command participates in a response phase. Within each phase, commands preserve response order and each handler's completion is awaited.
| Order | Phase |
|---|---|
| 1 | Redirect preflight. A redirect suppresses plugin commands and remaining response effects. |
| 2 | Plugin commands marked before. |
| 3 | Storage directives and existing JavaScript-before directives. |
| 4 | Out-of-band invocations and mutations in their response order. |
| 5 | Main target swap unless aborted; inserted behavior initialization/reconciliation. |
| 6 | Successful invocation history push/replace. |
| 7 | Plugin commands marked after, with relevant target behavior readiness. |
| 8 | Existing JavaScript-after directives. |
| Response condition | Command behavior |
|---|---|
| Successful HTTP response | Process registered commands in the requested phase. |
| Failed HTTP response | Strip command directives without executing them. |
| Server abort directive | Suppress the main swap and continue explicit commands/directives. |
| Redirect directive | Skip plugin commands and navigate. |
| Request replaced/cancelled | Suppress remaining response effects. Earlier completed effects are not rolled back. |
| Unknown/failed command | Report and skip that command; continue later commands. |
| Command inside an HTML template | Strip without execution. |
| Command markup in an ordinary initial document | Boot does not execute it as a response command. |
Directives are removed before normal HTML insertion. Cancellation and timeouts bound Heimdall's wait and abort the signal; they cannot forcibly stop JavaScript that ignores the signal. Design asynchronous handlers to cooperate and make server operations independently safe against repeated user requests.
Deliver commands over Bifrost
The same encoded command helper can be published as HTML over a normal authorized Bifrost topic. A plugin package does not need a second client transport.
await bifrost.PublishAsync(
topic: "orders",
content: HeimdallHtml.PluginCommand(
"company.editor.set-text",
new { Text = "Updated by the server" },
targetSelector: "#company-editor"),
ttl: TimeSpan.FromSeconds(10),
ct: cancellationToken);<div heimdall-sse-topic="orders" heimdall-sse-swap="none"></div>- Messages with plugin commands are serialized per subscription, including later messages while an asynchronous command is pending.
- Pause, disconnect, element disposal, or plugin removal cancels relevant pending processing.
- The pending-message queue is bounded by pluginSseQueueLimit. Overflow reports sse-command-queue-full and closes the subscription with plugin-command-queue-full.
- Delivery is not an exactly-once guarantee. Heimdall does not automatically retry a command handler.
- Authorize topics using the normal Bifrost policy/handler model; plugin command registration is not an authorization boundary.
Diagnostics and execution limits
Installation errors throw. Runtime mount, update, disposal, trigger, subscription, payload, and command failures are surfaced through heimdall:plugin-error.
document.addEventListener("heimdall:plugin-error", event => {
const { pluginId, extension, phase, code, invocationId } = event.detail;
reportPluginFailure({ pluginId, extension, phase, code, invocationId });
});The detail also includes error and available sourceElement context. Keep payloads, secrets, editor text, and user identifiers out of routine telemetry. A command error does not rewrite the HTTP status or undo DOM effects that already completed.
| Heimdall.config setting | Default | Purpose |
|---|---|---|
| pluginMountTimeoutMs | 10000 | Bound asynchronous mount/readiness waits. Zero disables this timeout. |
| pluginCommandTimeoutMs | 10000 | Bound each asynchronous command handler wait. Zero disables this timeout. |
| pluginCommandMaxArgsChars | 65536 | Bound serialized arguments per command, measured in characters. |
| pluginCommandMaxCount | 256 | Bound accepted commands in one response. |
| pluginSseQueueLimit | 128 | Bound pending messages per command-bearing SSE subscription. |
| Common diagnostic | Meaning |
|---|---|
| install-failed | Installation failed validation or threw; staged registrations were rolled back. |
| mount-failed / update-failed / dispose-failed | An element lifecycle hook failed. |
| subscribe-failed / trigger-failed | The event adapter or subscription failed. |
| unknown-payload / payload-failed | A missing or invalid reader prevented the normal payload flow. |
| unknown-command | No installed handler exists for that qualified command name. |
| command-failed | Invalid timing/JSON/arguments/target, or a handler failure. |
| command-limit | The response exceeded the configured command count. |
| plugin-timeout | A bounded mount or command operation exceeded its configured time. |
| sse-command-queue-full | The per-subscription pending command-message queue overflowed. |
Registered handlers receive structured data. The runtime does not evaluate server-supplied JavaScript source. A plugin script itself has normal page authority, so application owners choose and review their plugin dependencies.
TypeScript, JavaScript authoring, and the public SDK
The shipped heimdall-plugins.d.ts file defines HeimdallPlugin, descriptors, contexts, command validation, invocation options/results, registry metadata, and browser globals.
/_content/HeimdallFramework.Web/heimdall-plugins.d.tsReference that declaration artifact from your TypeScript setup or vendor the matching declaration file into your development types directory. It is a type file, not a script to load into the page. Pin it with the runtime version your package supports.
import type { HeimdallPlugin } from "./types/heimdall-plugins";
type EditorOptions = { commitKey?: string };
const editor: HeimdallPlugin<EditorOptions> = {
id: "company.editor",
pluginApiVersion: 1,
install(api, options) {
// Descriptor callbacks receive contextual SDK types.
}
};
export default editor;/** @type {import("./types/heimdall-plugins").HeimdallPlugin<{ commitKey?: string }>} */
const editorPlugin = {
id: "company.editor",
pluginApiVersion: 1,
install(api, options = {}) { /* ... */ }
};Use a type-predicate validate function when TypeScript should narrow command args for its handler. Private _build modules are implementation details and are not supported authoring imports. Additions within plugin API v1 are additive; incompatible signatures or lifecycle changes require a new plugin API major and an explicit migration.
Production integration tests and troubleshooting
Verify a plugin from a separate consuming project, using public assets and public APIs. Exercise the actual widget's lifecycle and the same browser/server boundary your users will run.
- Install with explicit options; test duplicate IDs, dependency order, rollback, and removal/reinstallation.
- Exercise initial document boot, repeated boot, moves, multiple inserted roots, append, inner/outer replacement, and external removals.
- Test async readiness, observed-attribute updates, a removed pending mount, timeouts, and resource cleanup.
- Verify native/custom/subscription triggers, built-in triggers with plugin readers, queue snapshot timing, and missing readers.
- Exercise real assembly discovery, scoped DI across requests, antiforgery, auth headers, authorization, and cancellation.
- Verify before/after commands, multiple targets, failures, redirects, aborts, stale response suppression, and Bifrost ordering.
- Run against both readable and minified runtime bundles; collect unexpected browser errors and plugin diagnostics.
In a matching framework checkout, Heimdall.E2E.TestPlugin is a standalone RCL consumed by Heimdall.E2E. Its /e2e/plugins workbench exposes manual controls and lifecycle/command/error logs; ?runtime=debug selects the readable runtime. It tests real HTTP and Bifrost connections.
# From Heimdall.E2E in the framework checkout:
npm run test:plugins
# From Heimdall.Web:
npm run test:types| Symptom | Check |
|---|---|
| Heimdall.use is missing | Confirm the page loaded a runtime that exposes pluginApiVersion 1; check stale/CDN assets. |
| Trigger never starts a request | Check installed plugin metadata, event name, qualified/default attribute, scope/ignore boundaries, and disabled state. |
| Initial load cannot read widget data | Install before boot and put asynchronous preparation in a managed behavior. |
| Command is unknown | Install its plugin before responses arrive; skipped commands have no replay queue. |
| A replaced widget leaks handlers | Return its destroy/unsubscribe cleanup or bind listeners to the mount signal. |
| Updates race after await | Honor the command/mount signal before applying effects and choose the appropriate invocation sync policy. |
| Options appear unchanged | Options are plugin-owned. Do not reinstall under the same ID with a different object; use the widget's observed attributes/update contract or remove/reinstall deliberately. |
Related guides
Use the server and runtime references alongside this page when building a reusable package.