Skip to documentation
Heimdall Docs
Heimdall Docs

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.

CapabilityUse it forPublic registration
BehaviorInitialize, update, and dispose an editor, picker, chart, or existing browser widget on matching elements.api.behaviors.register
TriggerTurn a DOM event or an external SDK callback into a normal Heimdall invocation.api.triggers.register
Payload sourceRead the data an invocation needs, independently of which trigger fired.api.payloads.register
CommandReceive 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.

HTML
<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>
JavaScript
// 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.

Text
Company.Editor/
  EditorRegistration.cs
  EditorPanel.cs
  wwwroot/
    js/editor-plugin.js
    css/editor.css

ConsumingApp/
  Program.cs
  wwwroot/js/install-plugins.js
JavaScript
// 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.

C#
// 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;
    }
}
C#
// 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.

C#
// 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();
CSS
/* Company.Editor/wwwroot/css/editor.css */
.company-editor-input {
    display: block;
    width: 100%;
    min-height: 8rem;
    margin-block: 0.5rem;
}
When you interactWhat happens
Ctrl+EnterManaged keyboard behavior emits company-editor-commit; the custom trigger invokes company.editor.save.
Save buttonBuilt-in click invokes the same method and reads the same plugin payload.
Server responseThe status HTML swaps into #editor-status, then company.editor.set-text applies the normalized text.
Reset buttonA static method returns status HTML and a command; both methods are discovered from the same library assembly.
Editor removedThe 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.

JavaScript
export default {
    id: "company.editor",
    version: "1.0.0",
    pluginApiVersion: 1,
    requires: ["company.foundation"],
    install(api, options) {
        // Register capabilities and cleanup synchronously.
    }
};
MemberRequiredContract
idYesLowercase dotted or hyphenated alphanumeric segments. The heimdall namespace is reserved.
pluginApiVersionYesUse 1 for this SDK contract. It is independent of the HTTP protocol's Heimdall.apiVersion.
versionNoDiagnostic package metadata. The runtime does not use it as a semantic-version compatibility resolver.
requiresNoPlugin IDs that must already be installed. The host chooses package versions and installation order.
install(api, options)YesRegisters 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.

JavaScript
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.
JavaScript
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 APIPurpose
api.pluginId / api.pluginApiVersionIdentify the owning plugin and SDK contract.
api.signalAborts 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.

JavaScript
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 resultMeaning
nameLocal capability name; qualified by the owning plugin.
selectorMount on the matching root and every matching descendant. Prefer explicit marker attributes.
observedAttributesAttributes whose changed values request update or remount.
mount(element, context)Context includes element, pluginId, the plugin options, and this mount's signal.
Return undefinedUse signal-owned listeners or other resources that need no separate cleanup.
Return cleanup functionCalled once when this active mount is disposed.
Return { update?, dispose? }Synchronous update for observed changes, and optional cleanup.
Return a promise of these resultsAsync 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.

OperationBehavior lifetime
Initial document or boot(root)Mount the matching root and matching descendants once per active match.
Repeated boot or a DOM movePreserve the active instance; do not attach another copy of its handlers.
Append/prependKeep existing instances and initialize every inserted root.
Inner swapDispose affected descendants before replacing them; preserve the target's own active instance if it still matches.
Outer swapDispose the replaced target and its affected subtree, then initialize the inserted roots.
Cancelled swap-beforeDo not dispose the current DOM merely because a replacement was proposed.
Marker removed or selector no longer matchesDispose that active behavior instance.
External removal with observation enabledReconcile the final document state asynchronously.
Plugin removedAbort its signals and dispose all its active mounts/subscriptions.
JavaScript
// 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.

JavaScript
api.triggers.register({
    name: "commit",
    events: ["company-editor-commit"],
    preventDefault: true,
    debounceMs: 150,
    filter: ({ event }) => event !== null
});
DescriptorContract
nameLocal name, qualified to company.editor.commit.
event / eventsOne native event name or an array of individual event names.
attributeOptional explicit trigger attribute. Built-in attributes cannot be overridden.
filter({ element, event })Optional synchronous eligibility check.
preventDefault / debounceMsPlugin defaults; normal markup modifiers participate in the shared trigger rules.
subscribeUse instead of native event descriptors for an external callback/event source.
HTML
<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.

JavaScript
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;
    }
});
EmissionMeaning
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 promiseResolves after invocation processing; cancelled/superseded debounced work can resolve null.
Returned cleanupSynchronous 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.

JavaScript
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.

SituationSnapshot rule
timing: triggerRead after debounce when the trigger starts; preserve the submitted data while queued.
timing: executionRe-read when queued execution begins. The source element and registration must still be available.
Explicit request-config payloadThe application override remains authoritative.
emit with explicit payloadUse the submitted explicit snapshot.
Antiforgery retryReuse the attempt's payload, rather than reading newer widget values.
Missing/failed readerReport the failure and do not send an empty replacement payload.
Removed execution bindingCancel with payload-source-unavailable.
C#
// 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.

JavaScript
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 contextMeaning
elementResolved target element, or null when no selector was supplied.
argsParsed JSON arguments after optional validation.
signalCommand lifetime; observes request/SSE/plugin cancellation and timeout.
pluginIdOwning plugin ID.
invocationIdContent invocation ID when available; null for a stream message without one.
sourceElementThe triggering element when available.
origininvocation or sse.
timingbefore 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.

C#
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");
HTML
<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.

OrderPhase
1Redirect preflight. A redirect suppresses plugin commands and remaining response effects.
2Plugin commands marked before.
3Storage directives and existing JavaScript-before directives.
4Out-of-band invocations and mutations in their response order.
5Main target swap unless aborted; inserted behavior initialization/reconciliation.
6Successful invocation history push/replace.
7Plugin commands marked after, with relevant target behavior readiness.
8Existing JavaScript-after directives.
Response conditionCommand behavior
Successful HTTP responseProcess registered commands in the requested phase.
Failed HTTP responseStrip command directives without executing them.
Server abort directiveSuppress the main swap and continue explicit commands/directives.
Redirect directiveSkip plugin commands and navigate.
Request replaced/cancelledSuppress remaining response effects. Earlier completed effects are not rolled back.
Unknown/failed commandReport and skip that command; continue later commands.
Command inside an HTML templateStrip without execution.
Command markup in an ordinary initial documentBoot 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.

C#
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);
HTML
<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.

JavaScript
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 settingDefaultPurpose
pluginMountTimeoutMs10000Bound asynchronous mount/readiness waits. Zero disables this timeout.
pluginCommandTimeoutMs10000Bound each asynchronous command handler wait. Zero disables this timeout.
pluginCommandMaxArgsChars65536Bound serialized arguments per command, measured in characters.
pluginCommandMaxCount256Bound accepted commands in one response.
pluginSseQueueLimit128Bound pending messages per command-bearing SSE subscription.
Common diagnosticMeaning
install-failedInstallation failed validation or threw; staged registrations were rolled back.
mount-failed / update-failed / dispose-failedAn element lifecycle hook failed.
subscribe-failed / trigger-failedThe event adapter or subscription failed.
unknown-payload / payload-failedA missing or invalid reader prevented the normal payload flow.
unknown-commandNo installed handler exists for that qualified command name.
command-failedInvalid timing/JSON/arguments/target, or a handler failure.
command-limitThe response exceeded the configured command count.
plugin-timeoutA bounded mount or command operation exceeded its configured time.
sse-command-queue-fullThe 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.

Text
/_content/HeimdallFramework.Web/heimdall-plugins.d.ts

Reference 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.

TypeScript
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;
JavaScript
/** @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.

Shell
# From Heimdall.E2E in the framework checkout:
npm run test:plugins

# From Heimdall.Web:
npm run test:types
SymptomCheck
Heimdall.use is missingConfirm the page loaded a runtime that exposes pluginApiVersion 1; check stale/CDN assets.
Trigger never starts a requestCheck installed plugin metadata, event name, qualified/default attribute, scope/ignore boundaries, and disabled state.
Initial load cannot read widget dataInstall before boot and put asynchronous preparation in a managed behavior.
Command is unknownInstall its plugin before responses arrive; skipped commands have no replay queue.
A replaced widget leaks handlersReturn its destroy/unsubscribe cleanup or bind listeners to the mount signal.
Updates race after awaitHonor the command/mount signal before applying effects and choose the appropriate invocation sync policy.
Options appear unchangedOptions 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.

Use the server and runtime references alongside this page when building a reusable package.