';
+ container.querySelector('#emit').addEventListener('click', function () {
+ if (typeof props.dynamiaEmit === 'function') {
+ props.dynamiaEmit({from: 'mount-fn', userId: props.userId});
+ }
+ });
+ }
+
+ window.mountDemoApp = function (container, props) {
+ container._dynamiaUpdateCount = 0;
+ renderMountDemoApp(container, props);
+ console.log('[demo-app] mounted via mount-fn', props);
+ };
+
+ /**
+ * Optional single-spa-style "update" hook (MicroFrontend's updateFn=): called instead of
+ * unmount+mount when only props changed, so the bundle can preserve its own state. Here we
+ * just bump a counter on the container to prove in the UI/console that no full remount happened.
+ */
+ window.updateDemoApp = function (container, props) {
+ container._dynamiaUpdateCount = (container._dynamiaUpdateCount || 0) + 1;
+ renderMountDemoApp(container, props);
+ console.log('[demo-app] updated in place via updateFn (#' + container._dynamiaUpdateCount + ')', props);
+ };
+
+ window.unmountDemoApp = function (container) {
+ container.innerHTML = '';
+ console.log('[demo-app] unmounted via mount-fn');
+ };
+})();
diff --git a/examples/demo-zk-books/src/main/resources/static/next/demo-app/assets/index-a1b2c3d4.js b/examples/demo-zk-books/src/main/resources/static/next/demo-app/assets/index-a1b2c3d4.js
new file mode 100644
index 00000000..abbbf8da
--- /dev/null
+++ b/examples/demo-zk-books/src/main/resources/static/next/demo-app/assets/index-a1b2c3d4.js
@@ -0,0 +1,18 @@
+/**
+ * Fake hashed Vite chunk used to manually verify MicroFrontend's "app" auto-discovery + "auto"
+ * mount mode: a plain self-mounting SPA bundle, like `createApp(App).mount('#demo-app-root')`
+ * would produce, with no custom element and no window mount/unmount functions.
+ */
+(function () {
+ var root = document.getElementById('demo-app-root');
+ if (!root) {
+ console.error('[demo-app] #demo-app-root not found, was the index.html body replicated?');
+ return;
+ }
+ console.log('[demo-app] self-mounted into #demo-app-root');
+ root.innerHTML =
+ '
';
+})();
diff --git a/examples/demo-zk-books/src/main/resources/static/next/demo-app/assets/index-e5f6g7h8.css b/examples/demo-zk-books/src/main/resources/static/next/demo-app/assets/index-e5f6g7h8.css
new file mode 100644
index 00000000..af2078b0
--- /dev/null
+++ b/examples/demo-zk-books/src/main/resources/static/next/demo-app/assets/index-e5f6g7h8.css
@@ -0,0 +1,6 @@
+/* Fake hashed Vite CSS chunk, loaded automatically via MicroFrontend's "app" discovery. */
+.demo-app-widget-box {
+ padding: 1rem;
+ border: 2px dotted #e17055;
+ border-radius: 8px;
+}
diff --git a/examples/demo-zk-books/src/main/resources/static/next/demo-app/index.html b/examples/demo-zk-books/src/main/resources/static/next/demo-app/index.html
new file mode 100644
index 00000000..0897a72f
--- /dev/null
+++ b/examples/demo-zk-books/src/main/resources/static/next/demo-app/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ demo-app
+
+
+
+
+
+
+
diff --git a/platform/packages/microfrontend-bridge/README.md b/platform/packages/microfrontend-bridge/README.md
new file mode 100644
index 00000000..e923bfc6
--- /dev/null
+++ b/platform/packages/microfrontend-bridge/README.md
@@ -0,0 +1,110 @@
+# @dynamia-tools/microfrontend-bridge
+
+> Typed helpers for JS/TS bundles (Vue, React, Svelte, plain JS) embedded via `tools.dynamia.zk.ui.MicroFrontend`.
+
+`MicroFrontend` embeds an external JS bundle inside a ZK page and talks to it through a small,
+ad-hoc protocol: an injected `dynamiaEmit(data)` callback to notify the server, and a
+`dynamiaHost` prop with server-registered context (tenant, locale, API base URL, ...). This
+package wraps that protocol in three small, fully-typed functions so bundle authors don't have to
+know the wire details or duck-type props by hand.
+
+Zero runtime dependencies, works with any bundler/framework — this only touches plain objects.
+
+## Installation
+
+```bash
+npm install @dynamia-tools/microfrontend-bridge
+```
+
+## API
+
+- `isDynamiaHost(target)` — whether `dynamiaEmit` was actually injected (false in local/standalone
+ dev, and always false in `MicroFrontend`'s `"auto"` mode, which has no prop channel at all).
+- `emitToHost(target, data)` — sends `data` back to the server, which fires `onMicrofrontendEvent`.
+ No-ops with a console warning (never throws) when not running inside a Dynamia host.
+- `getHostContext(target)` — reads the merged `dynamiaHost` context, or `{}` if none was registered.
+- Types: `DynamiaMicrofrontendProps`, `DynamiaHostContext`.
+
+In every function, `target` is whatever object actually carries the props: the custom element
+instance itself (`this`) in `MODE_CUSTOM_ELEMENT`, or the `props` argument in `MODE_MOUNT_FN`'s
+`mountFn`/`updateFn`.
+
+## Usage
+
+### Custom element (e.g. Vue's `defineCustomElement`)
+
+```ts
+import { defineCustomElement } from 'vue';
+import { emitToHost, getHostContext, type DynamiaMicrofrontendProps } from '@dynamia-tools/microfrontend-bridge';
+import App from './App.vue';
+
+const MyWidget = defineCustomElement({
+ props: ['userId'],
+ setup(props) {
+ // `this` inside a Vue defineCustomElement's methods is the element instance itself,
+ // which is exactly what MicroFrontend assigns dynamiaEmit/dynamiaHost onto.
+ function notifyHost(data: unknown) {
+ emitToHost(this as unknown as DynamiaMicrofrontendProps, data);
+ }
+ const host = getHostContext(this as unknown as DynamiaMicrofrontendProps);
+ // ...use props.userId, host.tenantId, host.locale, etc.
+ },
+});
+
+customElements.define('my-widget', MyWidget);
+```
+
+```xml
+
+
+```
+
+### `mount-fn` convention (single-spa style)
+
+```ts
+import { emitToHost, getHostContext, type DynamiaMicrofrontendProps } from '@dynamia-tools/microfrontend-bridge';
+
+window.mountMyApp = (container: HTMLElement, props: DynamiaMicrofrontendProps) => {
+ const host = getHostContext(props);
+ fetch(`${host.apiBaseUrl ?? '/api'}/things?tenant=${host.tenantId}`)
+ .then((res) => res.json())
+ .then((things) => render(container, things, () => emitToHost(props, { type: 'clicked' })));
+};
+
+window.updateMyApp = (container: HTMLElement, props: DynamiaMicrofrontendProps) => {
+ // called instead of a full unmount+mount when only props changed (MicroFrontend's updateFn=)
+ rerender(container, props);
+};
+
+window.unmountMyApp = (container: HTMLElement) => {
+ container.innerHTML = '';
+};
+```
+
+```xml
+
+
+```
+
+## Providing host context (Java side)
+
+```java
+@Component
+public class TenantHostContextProvider implements MicroFrontendHostContextProvider {
+ @Override
+ public Map getHostContext() {
+ Account account = AccountServiceAPI.getCurrentAccount();
+ return Map.of(
+ "tenantId", account.getId(),
+ "locale", LocaleUtils.getCurrentLocale().toLanguageTag(),
+ "apiBaseUrl", "/api"
+ );
+ }
+}
+```
+
+## License
+
+Apache-2.0
diff --git a/platform/packages/microfrontend-bridge/package.json b/platform/packages/microfrontend-bridge/package.json
new file mode 100644
index 00000000..40a6e13e
--- /dev/null
+++ b/platform/packages/microfrontend-bridge/package.json
@@ -0,0 +1,72 @@
+{
+ "name": "@dynamia-tools/microfrontend-bridge",
+ "version": "26.7.0",
+ "website": "https://dynamia.tools",
+ "description": "Typed helpers for JS/TS bundles (Vue, React, Svelte, plain JS) embedded via tools.dynamia.zk.ui.MicroFrontend",
+ "keywords": [
+ "dynamia",
+ "dynamia-platform",
+ "microfrontend",
+ "zk",
+ "custom-elements",
+ "typescript"
+ ],
+ "homepage": "https://dynamia.tools",
+ "bugs": {
+ "url": "https://github.com/dynamiatools/framework/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/dynamiatools/framework.git",
+ "directory": "platform/packages/microfrontend-bridge"
+ },
+ "license": "Apache-2.0",
+ "author": "Dynamia Soluciones IT SAS",
+ "type": "module",
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "development": "./src/index.ts",
+ "import": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "require": {
+ "types": "./dist/index.d.cts",
+ "default": "./dist/index.cjs"
+ }
+ }
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "scripts": {
+ "build": "vite build",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint src",
+ "clean": "rm -rf dist",
+ "prepublishOnly": "pnpm run build && pnpm run test"
+ },
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ },
+ "devDependencies": {
+ "@types/node": "^26.1.1",
+ "@vitest/coverage-v8": "^4.1.10",
+ "typescript": "^6.0.3",
+ "vite": "^8.1.4",
+ "vite-plugin-dts": "^5.0.3",
+ "vitest": "^4.1.10"
+ },
+ "engines": {
+ "node": ">=24"
+ }
+}
diff --git a/platform/packages/microfrontend-bridge/src/bridge.ts b/platform/packages/microfrontend-bridge/src/bridge.ts
new file mode 100644
index 00000000..71891a9e
--- /dev/null
+++ b/platform/packages/microfrontend-bridge/src/bridge.ts
@@ -0,0 +1,44 @@
+import type { DynamiaHostContext, DynamiaMicrofrontendProps } from './types.js';
+
+/**
+ * Whether `target` looks like it's actually running inside a `tools.dynamia.zk.ui.MicroFrontend`
+ * host, i.e. `dynamiaEmit` was injected. False for MicroFrontend's "auto" mode (no prop channel at
+ * all) and for local/standalone dev runs outside any Dynamia host.
+ *
+ * @param target a custom element instance (pass `this`) or the props object a mount-fn/updateFn receives
+ */
+export function isDynamiaHost(target: DynamiaMicrofrontendProps | null | undefined): boolean {
+ return typeof target?.dynamiaEmit === 'function';
+}
+
+/**
+ * Sends `data` back to the server-side MicroFrontend component hosting this bundle, which fires
+ * "onMicrofrontendEvent" server-side (bindable with `onMicrofrontendEvent="@command(...)"`). Works
+ * the same whether `target` is a custom element instance (pass `this`) or the `props` object a
+ * mount-fn/updateFn receives — both carry `dynamiaEmit` the same way. No-ops with a console warning
+ * instead of throwing when not running inside a Dynamia host (see {@link isDynamiaHost}), so
+ * bundles keep working standalone during local development.
+ *
+ * @param target a custom element instance (pass `this`) or the props object a mount-fn/updateFn receives
+ * @param data JSON-serializable payload, delivered as `event.getData()` server-side
+ */
+export function emitToHost(target: DynamiaMicrofrontendProps, data: unknown): void {
+ if (typeof target?.dynamiaEmit === 'function') {
+ target.dynamiaEmit(data);
+ } else if (typeof console !== 'undefined') {
+ console.warn(
+ '[@dynamia-tools/microfrontend-bridge] emitToHost() called but dynamiaEmit is not available ' +
+ '- not running inside a MicroFrontend host, or this is "auto" mode (no prop channel).'
+ );
+ }
+}
+
+/**
+ * Returns the host context merged from every registered `MicroFrontendHostContextProvider`, or
+ * `{}` if none were registered (or not running inside a Dynamia host).
+ *
+ * @param target a custom element instance (pass `this`) or the props object a mount-fn/updateFn receives
+ */
+export function getHostContext(target: DynamiaMicrofrontendProps | null | undefined): DynamiaHostContext {
+ return target?.dynamiaHost ?? {};
+}
diff --git a/platform/packages/microfrontend-bridge/src/index.ts b/platform/packages/microfrontend-bridge/src/index.ts
new file mode 100644
index 00000000..9df9a699
--- /dev/null
+++ b/platform/packages/microfrontend-bridge/src/index.ts
@@ -0,0 +1,2 @@
+export {emitToHost, getHostContext, isDynamiaHost} from './bridge.js';
+export type {DynamiaHostContext, DynamiaMicrofrontendProps} from './types.js';
diff --git a/platform/packages/microfrontend-bridge/src/types.ts b/platform/packages/microfrontend-bridge/src/types.ts
new file mode 100644
index 00000000..1816e92c
--- /dev/null
+++ b/platform/packages/microfrontend-bridge/src/types.ts
@@ -0,0 +1,36 @@
+/**
+ * Cross-cutting context values registered server-side via one or more
+ * `tools.dynamia.zk.ui.MicroFrontendHostContextProvider` beans, merged and delivered as the
+ * `dynamiaHost` prop on every mount (except MicroFrontend's "auto" mode, which has no prop channel
+ * at all). Shape is entirely app-defined; the fields below are just common examples.
+ */
+export interface DynamiaHostContext {
+ tenantId?: string;
+ locale?: string;
+ apiBaseUrl?: string;
+
+ [key: string]: unknown;
+}
+
+/**
+ * Shape of what `tools.dynamia.zk.ui.MicroFrontend` actually delivers to a mounted bundle:
+ * - In "custom-element" mode, these are assigned as properties directly on the element instance
+ * (`this.dynamiaEmit`, `this.dynamiaHost`, ...).
+ * - In "mount-fn" mode, they're the second argument passed to `mountFn(container, props)` /
+ * `updateFn(container, props)`.
+ *
+ * Any other key is an app-defined prop bound via a ZUL attribute (e.g. `userId="@bind(vm.userId)"`)
+ * or `MicroFrontend#addProp`/`#setProps`.
+ */
+export interface DynamiaMicrofrontendProps {
+ /**
+ * Sends `data` back to the server-side MicroFrontend component, which fires
+ * "onMicrofrontendEvent" (bindable with `onMicrofrontendEvent="@command(...)"`). Undefined
+ * when not running inside a Dynamia host — see {@link isDynamiaHost}.
+ */
+ dynamiaEmit?: (data: unknown) => void;
+ /** Merged {@link DynamiaHostContext} from every registered MicroFrontendHostContextProvider. */
+ dynamiaHost?: DynamiaHostContext;
+
+ [key: string]: unknown;
+}
diff --git a/platform/packages/microfrontend-bridge/test/bridge.test.ts b/platform/packages/microfrontend-bridge/test/bridge.test.ts
new file mode 100644
index 00000000..c5c8c7d6
--- /dev/null
+++ b/platform/packages/microfrontend-bridge/test/bridge.test.ts
@@ -0,0 +1,41 @@
+import {describe, expect, it, vi} from 'vitest';
+import {emitToHost, getHostContext, isDynamiaHost} from '../src/bridge.js';
+
+describe('isDynamiaHost', () => {
+ it('is true when dynamiaEmit is a function', () => {
+ expect(isDynamiaHost({dynamiaEmit: () => undefined})).toBe(true);
+ });
+
+ it('is false when dynamiaEmit is missing or not a function', () => {
+ expect(isDynamiaHost({})).toBe(false);
+ expect(isDynamiaHost(null)).toBe(false);
+ expect(isDynamiaHost(undefined)).toBe(false);
+ });
+});
+
+describe('emitToHost', () => {
+ it('calls dynamiaEmit with the given data', () => {
+ const dynamiaEmit = vi.fn();
+ emitToHost({dynamiaEmit}, {foo: 'bar'});
+ expect(dynamiaEmit).toHaveBeenCalledWith({foo: 'bar'});
+ });
+
+ it('warns instead of throwing when dynamiaEmit is missing', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ expect(() => emitToHost({}, {foo: 'bar'})).not.toThrow();
+ expect(warn).toHaveBeenCalledOnce();
+ warn.mockRestore();
+ });
+});
+
+describe('getHostContext', () => {
+ it('returns dynamiaHost when present', () => {
+ expect(getHostContext({dynamiaHost: {tenantId: 't1'}})).toEqual({tenantId: 't1'});
+ });
+
+ it('returns {} when absent', () => {
+ expect(getHostContext({})).toEqual({});
+ expect(getHostContext(null)).toEqual({});
+ expect(getHostContext(undefined)).toEqual({});
+ });
+});
diff --git a/platform/packages/microfrontend-bridge/tsconfig.build.json b/platform/packages/microfrontend-bridge/tsconfig.build.json
new file mode 100644
index 00000000..8bfff951
--- /dev/null
+++ b/platform/packages/microfrontend-bridge/tsconfig.build.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "noEmit": false,
+ "declaration": true,
+ "declarationMap": true
+ },
+ "include": ["src"]
+}
diff --git a/platform/packages/microfrontend-bridge/tsconfig.json b/platform/packages/microfrontend-bridge/tsconfig.json
new file mode 100644
index 00000000..d6d13237
--- /dev/null
+++ b/platform/packages/microfrontend-bridge/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "customConditions": ["development"],
+ "noEmit": true
+ },
+ "include": ["src", "test"]
+}
diff --git a/platform/packages/microfrontend-bridge/vite.config.ts b/platform/packages/microfrontend-bridge/vite.config.ts
new file mode 100644
index 00000000..8246c875
--- /dev/null
+++ b/platform/packages/microfrontend-bridge/vite.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig } from 'vite';
+import dts from 'vite-plugin-dts';
+import { resolve } from 'path';
+
+export default defineConfig({
+ plugins: [
+ dts({
+ include: ['src'],
+ outDir: 'dist',
+ insertTypesEntry: true,
+ tsconfigPath: './tsconfig.build.json',
+ }),
+ ],
+ build: {
+ lib: {
+ entry: resolve(__dirname, 'src/index.ts'),
+ name: 'DynamiaMicrofrontendBridge',
+ formats: ['es', 'cjs'],
+ fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'),
+ },
+ rollupOptions: {
+ external: [],
+ },
+ sourcemap: true,
+ minify: false,
+ },
+});
diff --git a/platform/packages/microfrontend-bridge/vitest.config.ts b/platform/packages/microfrontend-bridge/vitest.config.ts
new file mode 100644
index 00000000..001cbcb7
--- /dev/null
+++ b/platform/packages/microfrontend-bridge/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'lcov'],
+ },
+ },
+});
diff --git a/platform/ui/zk/src/main/java/tools/dynamia/zk/ComponentAliasIndex.java b/platform/ui/zk/src/main/java/tools/dynamia/zk/ComponentAliasIndex.java
index e51fc592..cc755081 100644
--- a/platform/ui/zk/src/main/java/tools/dynamia/zk/ComponentAliasIndex.java
+++ b/platform/ui/zk/src/main/java/tools/dynamia/zk/ComponentAliasIndex.java
@@ -86,6 +86,7 @@ public class ComponentAliasIndex extends HashMap
+ * The bundle is loaded once per {@code src} (loads are cached/shared client-side across every
+ * {@code MicroFrontend} instance on the page) and mounted using one of two strategies, selected
+ * with {@link #setMode(String)}:
+ *
+ *
{@link #MODE_CUSTOM_ELEMENT} (default): the bundle registers a
+ * Custom Element
+ * (e.g. via {@code customElements.define(...)}, or Vue's {@code defineCustomElement}). This
+ * component creates that element, assigns {@link #setProps(Map)} as properties/attributes and
+ * appends it to its container. The browser mounts/unmounts it automatically through the
+ * element's own {@code connectedCallback}/{@code disconnectedCallback}.
+ *
{@link #MODE_MOUNT_FN}: the bundle exposes global mount/unmount functions (a convention
+ * similar to single-spa), invoked explicitly as {@code window[mountFn](container, props)} and
+ * {@code window[unmountFn](container)} when this component is rendered and detached. If the
+ * bundle also exposes {@link #setUpdateFn}, prop-only changes call
+ * {@code window[updateFn](container, props)} instead of unmounting/remounting, so the bundle
+ * can preserve its internal state (e.g. an in-progress form) across a reactive prop update.
+ *
{@link #MODE_AUTO}: for a bundle that self-mounts into a hardcoded selector at import
+ * time, as a default {@code vite build} of an app (not a custom-element library) does, e.g.
+ * {@code createApp(App).mount('#app')}. Selected automatically when {@link #setApp(String)} is
+ * used and neither {@link #setTag} nor {@link #setMountFn} is set: the {@code } markup
+ * of the discovered {@code index.html} (its mount target element(s), stripped of any
+ * {@code