WebUI Framework - AI Reference #

Single-page reference for LLMs. This page contains everything an AI coding assistant needs to generate correct WebUI Framework code. It covers the SSR model, template syntax, component authoring, CLI commands, and known constraints. Bookmark this page and feed it to your AI when working with WebUI.

What is WebUI Framework? #

WebUI is a language-agnostic server-side rendering framework. Templates are compiled to a binary Protocol Buffer at build time. At runtime, any backend (Rust, Node, Go, C#, Python) fills in JSON state and produces HTML. On the client, interactive Web Components hydrate as islands.

Key facts an AI must know:

  • The server renders HTML from a compiled binary + JSON state. No JavaScript runs on the server.
  • Only interactive components ship JavaScript to the browser.
  • Templates are declarative: HTML for structure, CSS for styling, TypeScript for behavior. They are always in separate files.
  • There is no JSX, no CSS-in-JS, no template literals, no virtual DOM.

The SSR Mental Model #

BUILD TIME          SERVER RENDER         CLIENT HYDRATION
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€       โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€        โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
HTML + CSS + TS โ†’   protocol.bin    โ†’     Web Components
webui build         + JSON state          hydrate as islands
                    โ†’ rendered HTML

Rules #

  1. Every template binding must exist in the server state JSON.If your template uses {{title}}, the server must provide{ "title": "..." }.

  2. Derived state belongs in the server or the template. Use template expressions like items.length or status == 'active' for simple derivations. For complex values, compute on the server.

  3. The server is the source of truth for the initial render. The client takes over after hydration for user interactions.

  4. Static content never ships JavaScript. Only components with event handlers, reactive state, or user input need client-side code.

Project Structure #

my-app/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ index.html              โ† Entry template
โ”‚   โ”œโ”€โ”€ index.ts                โ† Hydration entry point
โ”‚   โ”œโ”€โ”€ my-component/
โ”‚   โ”‚   โ”œโ”€โ”€ my-component.html   โ† Component template
โ”‚   โ”‚   โ”œโ”€โ”€ my-component.css    โ† Component styles (scoped)
โ”‚   โ”‚   โ””โ”€โ”€ my-component.ts     โ† Component behavior
โ”‚   โ””โ”€โ”€ other-widget/
โ”‚       โ”œโ”€โ”€ other-widget.html
โ”‚       โ”œโ”€โ”€ other-widget.css
โ”‚       โ””โ”€โ”€ other-widget.ts
โ”œโ”€โ”€ data/
โ”‚   โ””โ”€โ”€ state.json              โ† Server state for dev
โ””โ”€โ”€ package.json

Component discovery rules:

  • HTML files with a hyphen in the name are components (my-card.html โ†’ <my-card>)
  • CSS files with the same name are auto-paired (my-card.css)
  • TypeScript files provide client-side behavior (my-card.ts)
  • Discovery is recursive through subdirectories

The Tag #

The <template shadowrootmode="open"> wrapper is optional in component HTML files. The build tool auto-injects it when absent.

Omit it for most components (the framework wraps your content automatically):

<!-- my-card.html -->
<h2>{{title}}</h2>
<p>{{description}}</p>

Include it when you need root host events on the shadow root itself:

<!-- todo-app.html -->
<template shadowrootmode="open"
  @toggle-item="{onToggleItem(e)}"
  @delete-item="{onDeleteItem(e)}"
>
  <for each="item in items">
    <todo-item id="{{item.id}}"></todo-item>
  </for>
</template>

Root host events catch custom events bubbling up from child components. This is the delegated event pattern for parent-child communication.

Template Syntax #

Text binding #

<span>{{user.name}}</span>
<p>{{items.length}} items</p>
  • {{expr}} - HTML-escaped output (safe for user input)
  • {{{expr}}} - raw/unescaped output (only for trusted content)

Conditionals #

<if condition="isLoggedIn">
  <p>Welcome back, {{username}}!</p>
</if>

<if condition="!hasItems">
  <p>No items found.</p>
</if>

<if condition="status == 'active'">
  <span class="badge">Active</span>
</if>

Supported operators: ==, !=, >, <, >=, <=, &&, ||, !

Constraints:

  • Maximum 5 logical operators per expression
  • Cannot mix && and || in the same expression
  • No parentheses for grouping
  • No ternary operator (? :)

Loops #

<for each="item in items">
  <div>{{item.name}} - {{item.price}}</div>
</for>
  • The collection must be a JSON array
  • Nested loops are supported; outer loop variables remain accessible
  • Components inside loops do NOT inherit loop variables. Pass data via attributes:
    <for each="contact in contacts">
      <contact-card name="{{contact.name}}" email="{{contact.email}}"></contact-card>
    </for>
    

Attributes #

<!-- Dynamic attribute -->
<a href="{{url}}">{{linkText}}</a>

<!-- Boolean attribute (rendered when truthy, omitted when falsy) -->
<button ?disabled="{{isLoading}}">Submit</button>
<input type="checkbox" ?checked="{{isSelected}}" />

<!-- Boolean attributes accept the same expressions as <if condition="...">.
     Use comparisons against existing state instead of creating mirror observables. -->
<button ?disabled="{{currentIndex == 0}}">Prev</button>
<button ?disabled="{{currentIndex == items.length - 1}}">Next</button>
<option ?selected="{{item.id == selectedId}}">{{item.name}}</option>

<!-- Mixed static + dynamic -->
<img src="/img/{{user.avatar}}" alt="{{user.name}}" />

<!-- Complex/property binding -->
<my-widget :config="{{settings}}"></my-widget>

Events (client-side only) #

<button @click="{handleClick()}">Click me</button>
<input @keydown="{onKeydown(e)}" />
<div @mouseenter="{onHover()}" @mouseleave="{onLeave()}">Hover</div>

DOM references #

<input w-ref="searchInput" type="text" />

In the TypeScript class: searchInput!: HTMLInputElement;

Routes #

<route path="/" component="app-shell">
  <route path="" component="home-page" exact />
  <route path="users" component="user-list" exact />
  <route path="users/:id" component="user-detail" exact />
</route>

Path & matching:

  • Child paths are relative to parent (no leading /)
  • Use exact on leaf routes (no children)
  • Omit exact on parent routes that have <outlet />
  • Path params: :id (required), :query? (optional), *path (catch-all)

Attributes on <route>:

AttributeExampleDescription
path"users/:id"URL path template (relative to parent)
component"user-detail"Component tag to mount
exact(boolean)Require exact path match (use on leaf routes)
query"action,to,subject"Allowlist of query params set as component attributes (deny-by-default)
keep-alive(boolean)Preserve DOM and local state across navigations
cache-tags"thread:{threadId},inbox"Cache tag templates - {param} resolved at render time
invalidates"inbox,sent,counts"Tags to auto-invalidate after mutation actions
pending"loading-skeleton"Component for loading UI during slow navigations (>150ms)
error"error-display"Component for error boundary on fetch failure

All attributes are validated at build time. Referencing a non-existent pending or error component is a compile error.

State flow:

  • keep-alive โ€” preserves DOM and local state. On reactivation, only param/query attrs are updated - setState() is NOT called
  • Route loaders: static loader({ params, query, signal }) on component class - fetches custom data instead of server state. Runs pre-commit. Falls back to server state on failure
  • Keep-alive + loader: DOM preserved, loader provides fresh data via setState() on reactivation
  • Route actions: static action({ formData, params, signal }) on component class - handles <form method="post">. Returns { invalidateTags?, state? }. Auto-invalidates cache with merged tags

Cache & preload:

  • Preload on hover: Router.start({ preload: true }) - speculatively fetches on link hover
  • Tagged cache: Router.start({ cache: { staleTime, gcTime, maxEntries } }) - responses cached by path, tagged with cacheTags
  • Router.invalidateTags(tags) - evict cache entries by tag
  • Router.invalidate(path?) - evict by path or all

Server headers:

  • X-WebUI-Inventory header: hex bitmask of loaded templates - server skips re-sending

Outlet #

<!-- Parent component template -->
<nav>...</nav>
<main><outlet /></main>

Component Class #

Every interactive component extends WebUIElement:

import { WebUIElement, attr, observable } from '@microsoft/webui-framework';

export class MyComponent extends WebUIElement {
  // --- Decorators ---

  // @attr: reflects to/from HTML attribute (kebab-case)
  // String mode (default):
  @attr label = 'Default';

  // Boolean mode (present = true, absent = false):
  @attr({ mode: 'boolean' }) disabled = false;

  // @observable: reactive state, changes trigger DOM updates
  @observable count = 0;
  @observable items: Item[] = [];

  // Derived state: computed in event handlers, not as a getter
  @observable totalPrice = 0;

  private recalcTotal(): void {
    this.totalPrice = this.items.reduce((sum, i) => sum + i.price, 0);
  }

  // --- DOM refs (populated by w-ref="name" in template) ---
  inputEl!: HTMLInputElement;

  // --- Event handlers (referenced by @event in template) ---
  onSubmit(): void {
    const text = this.inputEl.value.trim();
    if (!text) return;
    this.items = [...this.items, { text, price: 0 }];
    this.inputEl.value = '';
  }

  onKeydown(e: KeyboardEvent): void {
    if (e.key === 'Enter') this.onSubmit();
  }

  // --- Custom events (child-to-parent communication) ---
  onItemDelete(e: CustomEvent<{ id: string }>): void {
    this.items = this.items.filter(i => i.id !== e.detail.id);
  }
}

// Register as custom element
MyComponent.define('my-component');

Decorator reference #

DecoratorPurposeSSR?Triggers DOM update?
@attrHTML attribute reflectionYes (from JSON state)Yes
@attr({ mode: 'boolean' })Boolean attribute (present/absent)YesYes
@observableReactive internal stateYes (from JSON state)Yes

Component API #

Method/PropertyDescription
this.$emit(name, detail?)Dispatch a CustomEvent that bubbles up
this.$update()Force a reactive update cycle
this.$flushUpdates()Synchronously flush pending updates
setState(state)Populate from router navigation state
static define(tagName)Register as a custom element

Emitting custom events #

// Child component
this.$emit('item-selected', { id: this.id, name: this.name });
<!-- Parent template catches it -->
<child-component @item-selected="{onItemSelected(e)}"></child-component>
// Parent handler
onItemSelected(e: CustomEvent): void {
  this.selectedId = e.detail.id;
}

Dynamic Component Loading #

Components like dialogs, overlays, and drawers are declared as routes but loaded on demand โ€” not during initial navigation. Declare them in the route tree so the build compiles them into the protocol:

<route path="/" component="app-shell">
  <route path="" component="home-page" exact />
  <route path="users/:id" component="user-detail" exact />
  <!-- Compiled into protocol, but only loaded when needed -->
  <route path="settings" component="settings-dialog" exact />
</route>

Then load on demand with Router.ensureLoaded before creating the element:

// Fetches template + CSS from /_webui/templates โ€” no FOUC
await Router.ensureLoaded('settings-dialog');
this.showSettings = true;

// Batch multiple in one request
await Router.ensureLoaded('modal-a', 'modal-b', 'drawer-c');

The component's template is not sent during initial SSR or partial navigation โ€” collect_inventoryable_components only walks the matched route, not siblings. Zero cost until requested.

If a user navigates directly to /settings (deep link), the component renders normally in the outlet โ€” it works both ways.

Configure a custom endpoint if needed:

Router.start({
  templateEndpoint: '/api/templates', // default: '/_webui/templates'
  loaders: { ... },
});

Component CSS #

CSS is scoped per component via Shadow DOM. No CSS-in-JS.

/* my-component.css */
:host {
  display: block;
  padding: 1rem;
}

:host([disabled]) {
  opacity: 0.5;
  pointer-events: none;
}

:host([variant="primary"]) {
  background: var(--colorBrandBackground);
}

/* Internal elements */
.header { font-weight: bold; }
.content { padding: 0.5rem; }

Rules:

  • :host styles the component root element
  • :host([attr]) styles based on attribute presence/value
  • Internal selectors are scoped to the shadow root
  • Use CSS custom properties (var(--name)) for theming
  • No styles leak in or out

Entry Template #

<!DOCTYPE html>
<html lang="en" dir="{{textdirection}}">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{{title}}</title>
</head>
<body>
  <app-shell></app-shell>
  <script type="module" src="/index.js"></script>
</body>
</html>

Hydration Entry Point #

// index.ts
import { WebUIElement } from '@microsoft/webui-framework';

// Import components to register them as custom elements.
// Registration triggers hydration automatically.
import './app-shell/app-shell.js';
import './user-card/user-card.js';

// Optional: listen for hydration completion
window.addEventListener('webui:hydration-complete', () => {
  const total = performance.getEntriesByName('webui:hydrate:total', 'measure')[0];
  console.log(`Hydration: ${total?.duration.toFixed(1)}ms`);
});

Hydration with Router #

// index.ts
import { WebUIElement } from '@microsoft/webui-framework';
import { Router } from '@microsoft/webui-router';
import './app-shell/app-shell.js';

// Start router after components are registered
Router.start({
  loaders: {
    'home-page': () => import('./pages/home-page.js'),
    'user-list': () => import('./pages/user-list.js'),
    'user-detail': () => import('./pages/user-detail.js'),
  },
});

View Transitions #

The router wraps every client-side navigation in document.startViewTransition()automatically. Do not wrap Router.navigate() in your own startViewTransition()โ€” that would double-transition.

To customize the animation, assign view-transition-name to elements in your CSS and target them with ::view-transition-old() / ::view-transition-new():

.content-area { view-transition-name: content; }

::view-transition-old(content) { animation: fade-out 100ms ease-out; }
::view-transition-new(content) { animation: fade-in 150ms ease-in; }

The router awaits updateCallbackDone (not .finished) so rapid navigations supersede each other without queuing.

CLI Commands #

Build #

webui build ./src --out ./dist --plugin=webui
FlagDefaultDescription
APP (positional).App source directory
--out <DIR>requiredOutput directory
--entry <FILE>index.htmlEntry HTML file
--css <MODE>linklink, style, or module
--dom <MODE>shadowshadow or light
--plugin <NAME>nonewebui or fast-v3
--components <PACKAGE>noneExtra component sources (repeatable)

Serve (dev server) #

webui serve ./src --state ./data/state.json --plugin=webui --watch
FlagDefaultDescription
APP (positional).App source directory
--state <FILE>requiredJSON state file
--port <PORT>3000Server port
--watchfalseEnable live reload
--servedir <DIR>noneStatic asset directory
--entry <FILE>index.htmlEntry HTML file
--css <MODE>linklink, style, or module
--dom <MODE>shadowshadow or light
--plugin <NAME>nonewebui or fast-v3
--components <PACKAGE>noneExtra component sources (repeatable)
--api-port <PORT>noneProxy route requests to API server
--theme <PACKAGE>noneDesign token theme (see below)

Inspect #

webui inspect ./dist/protocol.bin
webui inspect ./dist/protocol.bin | jq '.fragments | keys'

- External Component Sources #

The --components flag discovers components from npm packages or local directories outside your app folder. Repeatable.

What it accepts:

  • npm package name - @scope/my-widgets or my-widget
  • Scoped prefix - @scope (discovers ALL sub-packages under node_modules/@scope/)
  • Local path - ./shared/components or /libs/ui-kit

Values starting with ., /, \, or a drive letter are treated as local paths. Everything else is treated as an npm package name.

npm package requirements:

The package's package.json must have:

{
  "name": "@scope/my-button",
  "customElements": "./custom-elements.json",
  "exports": {
    "./template-webui.html": "./dist/template-webui.html",
    "./styles.css": "./dist/styles.css"
  }
}
FieldRequiredPurpose
exports["./template-webui.html"]YesComponent HTML template
exports["./styles.css"]NoComponent CSS
customElementsYesPath to Custom Elements Manifest (provides tag name)

Local path scanning works like app directory scanning: HTML files with hyphenated names are registered as components, matching CSS files are auto-paired.

Caching: npm results are cached at ~/.webui/cache/components/ and invalidated when package.json changes. Local paths are always re-scanned.

# Single scoped package
webui build ./src --out ./dist --components @reactive-ui/button

# All packages under a scope
webui build ./src --out ./dist --components @reactive-ui

# Local shared library
webui build ./src --out ./dist --components ./shared/components

# Multiple sources
webui build ./src --out ./dist \
  --components @reactive-ui \
  --components ./shared/components

- Design Token Themes #

The --theme flag loads a token JSON file and injects resolved CSS custom property declarations into the render state. Only available on webui serve.

What it accepts:

  • Local JSON file - ./themes/dark.json
  • npm package - @my-org/brand-tokens (looks for tokens.json inside the package)
  • npm package with subpath - @my-org/brand-tokens/custom.json

How it works:

  1. Loads the JSON file (multi-theme or flat single-theme format)
  2. Filters tokens to only those actually used in your CSS (var(--name))
  3. Expands transitive var() references and detects cycles
  4. Generates CSS declaration strings per theme
  5. Injects into state as state.tokens.light, state.tokens.dark, etc.

Multi-theme format:

{
  "themes": {
    "light": {
      "surface-page": "#ffffff",
      "text-primary": "#111827"
    },
    "dark": {
      "surface-page": "#171717",
      "text-primary": "#fafafa"
    }
  }
}

Flat format (single theme, treated as "default"):

{
  "surface-page": "#ffffff",
  "text-primary": "#111827"
}

Template usage - use a comment-based signal to inject tokens:

<style>
  :root {
    <!--{{{tokens.light}}}-->
  }
</style>

The handler resolves tokens.light from the state, outputting:

:root {
  --surface-page: #ffffff;
  --text-primary: #111827;
}

State JSON #

{
  "title": "My App",
  "user": { "name": "Alice", "role": "admin" },
  "items": [
    { "id": "1", "label": "First", "done": false },
    { "id": "2", "label": "Second", "done": true }
  ],
  "isAdmin": true,
  "showBanner": false
}

Path resolution: title, user.name, items.0.label, items.length

Missing paths: text bindings render empty, <if> evaluates to false. No error.

Truthiness in Conditions #

ValueTruthy?
trueYes
falseNo
0No
Non-zero numberYes
"" (empty string)No
"false" (string!)Yes (non-empty string)
null / missing keyNo

Never use string "false" for boolean state. Use real booleans.

Things You CANNOT Do #

  1. No ternary in templates. {{x ? 'yes' : 'no'}} does not work. Use <if> blocks or boolean attributes instead.

  2. No function calls in bindings. {{formatDate(item.date)}} does not work. Compute the value on the server or in an event handler.

  3. No mixed && and ||. <if condition="a && b || c"> is invalid. Split into nested <if> blocks.

  4. No parentheses in conditions. <if condition="(a && b) || c"> is invalid.

  5. No JavaScript in HTML templates. Templates are compiled to binary. Logic goes in the TypeScript class file, not the template.

  6. No JavaScript in CSS. CSS is plain CSS in .css files. Use CSS custom properties for dynamic values.

  7. No computed getters for SSR state. If a value appears in the template, it must be in the server state JSON. Use @observablewith explicit updates in event handlers.

  8. Components inside <for> loops do NOT inherit loop variables.Pass data explicitly via attributes.

  9. No import or require in templates. Components are discovered by file naming convention, not imports.

  10. No this.querySelector() for reactive state. Use @observable and template bindings. Use w-ref only for imperative DOM access (focus, scroll, etc.).

Common Patterns #

Toggle visibility #

<button @click="{togglePanel()}">Toggle</button>
<if condition="isPanelOpen">
  <div class="panel">Panel content</div>
</if>
@observable isPanelOpen = false;
togglePanel(): void { this.isPanelOpen = !this.isPanelOpen; }

List with add/remove #

<input w-ref="input" @keydown="{onKey(e)}" />
<for each="item in items">
  <div>
    {{item.text}}
    <button @click="{remove(item.id)}">ร—</button>
  </div>
</for>

Note: remove(item.id) does not work as written because template event handlers cannot pass arguments from loop scope. Instead, use a child component that emits a custom event:

<for each="item in items">
  <list-item id="{{item.id}}" text="{{item.text}}"
    @remove-item="{onRemove(e)}">
  </list-item>
</for>

Boolean attribute styling #

<button ?data-active="{{isActive}}" @click="{toggle()}">
  {{label}}
</button>
button[data-active] { background: blue; color: white; }
button:not([data-active]) { background: transparent; }

Lazy-loaded dialog #

Declare the component as a route (so it's compiled), then load dynamically:

<!-- index.html โ€” settings-dialog is in the protocol but not navigated to -->
<route path="/" component="app-shell">
  <route path="" component="home-page" exact />
  <route path="settings" component="settings-dialog" exact />
</route>
// Shell component โ€” load template + CSS on demand
async onOpenSettings(): Promise<void> {
  await Router.ensureLoaded('settings-dialog');
  await import('./settings-dialog/settings-dialog.js');
  this.showSettings = true;
}
<!-- Shell template โ€” create the element dynamically -->
<if condition="showSettings">
  <settings-dialog @close="{onCloseSettings()}"></settings-dialog>
</if>

Derived state (prefer template expressions over shadow observables) #

Anywhere an expression works (<if condition="...">, ?boolAttr="{{...}}", text bindings via path lookups), compare existing state directly. Do not introduce extra observables that just mirror a comparison or sentinel of other state, and do not bake per-item flags like isCurrent / isDisabled into the SSR JSON when a single comparison can derive them.

<!-- DO: derive in the template -->
<if condition="items.length">
  <span>{{items.length}} items</span>
</if>

<button ?disabled="{{currentIndex == 0}}">Prev</button>
<button ?disabled="{{currentIndex == totalItems}}">Next</button>

<for each="app in apps">
  <option ?selected="{{app.slug == currentApp.slug}}">{{app.name}}</option>
</for>

<!-- DON'T: shadow observables that mirror a comparison -->
<!-- @observable hasItems = false;     // mirrors items.length > 0 -->
<!-- @observable prevDisabled = true;  // mirrors currentIndex == 0 -->
<!-- @observable nextDisabled = false; // mirrors currentIndex == totalItems -->

<!-- DON'T: per-item flags in JSON state that mirror a comparison -->
<!-- { "apps": [{ "slug": "...", "isCurrent": true }, ...] }
     Just compare against the selected slug/id in the template. -->

Loop variables (e.g. app) compose with outer component state (e.g.currentApp) inside the same expression, so per-iteration flags are almost never needed.

Text bindings only do path lookups โ€” they can't do arithmetic. If you need{{currentIndex + 1}} for a 1-based display, that's a legitimate @observable(or precomputed in the SSR state).

Route-scoped state #

Each route handler should return only the state that route's component needs:

// GET /inbox -> only inbox data
{ "threads": [...], "selectedFolder": "inbox" }

// GET /settings -> only settings data
{ "theme": "dark", "language": "en" }

package.json #

{
  "scripts": {
    "build": "webui build ./src --out ./dist --plugin=webui",
    "dev": "webui serve ./src --state ./data/state.json --plugin=webui --watch"
  },
  "dependencies": {
    "@microsoft/webui": "latest",
    "@microsoft/webui-framework": "latest"
  }
}

Add @microsoft/webui-router if using client-side navigation.

Language Integration (Server Side) #

WebUI renders from any backend. The server loads protocol.bin once and renders with JSON state per request.

Rust #

let protocol = WebUIProtocol::from_protobuf(&fs::read("dist/protocol.bin")?)?;
let state = json!({ "title": "Home", "items": items_vec });
let mut handler = WebUIHandler::new();
handler.handle(&protocol, &state, &options, &mut writer)?;

Node.js #

import { render } from '@microsoft/webui';
const protocol = readFileSync('./dist/protocol.bin');
const html = render(protocol, JSON.stringify(state), 'index.html', req.url);

Python (FFI) #

ptr = lib.webui_render(html_bytes, json_bytes)
result = ctypes.cast(ptr, c_char_p).value.decode("utf-8")
lib.webui_free(ptr)

Go (cgo) #

ptr := C.webui_render(cHTML, cJSON)
defer C.webui_free(ptr)
result := C.GoString(ptr)

C# (P/Invoke) #

IntPtr ptr = webui_render(html, dataJson);
string result = Marshal.PtrToStringUTF8(ptr);
webui_free(ptr);

Server Template Endpoint #

For Router.ensureLoaded(), expose GET /_webui/templates?t=tag1,tag2:

let result = route_handler::render_component_templates(&protocol, &tags, &inv);
// Node native addon
const result = renderComponentTemplates(protocolBuf, JSON.stringify(tags), invHex);

// @microsoft/webui npm package
import { renderComponentTemplates } from '@microsoft/webui';
const result = renderComponentTemplates(protocolBuf, ['settings-dialog'], invHex);