Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions docs/frontend/INLINE_ZK_EMBED.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Inline (no-iframe) embedding of ZK views

Embed a server-rendered ZK view/page into a Vue (or any) host **without an iframe**, same origin only.
Issue: #113. Code: `platform/packages/ui-core/src/embed/inline.ts`, `<dynamia-embed mode="inline">`,
`<DynamiaZkEmbed>` in `@dynamia-tools/vue`.

## Usage

```vue
<!-- Vue -->
<DynamiaZkEmbed src="/page-embed/library/books" @load="onLoad" @error="onError" />
```

```html
<!-- Any page: <script src=".../dynamia-embed.global.js"> or registerDynamiaEmbed() -->
<dynamia-embed mode="inline" src="/books"></dynamia-embed>
```

```ts
// Programmatic
import { mountInline } from '@dynamia-tools/ui-core/embed';
const handle = await mountInline(container, '/books');
// ...
handle.destroy(); // releases the ZK desktop client- and server-side
```

`src` can be any same-origin URL that answers with a ZK-rendered document: a plain `.zul` view (`/books`) or a
navigation page through `PageEmbedController` (`/page-embed/<module>/<group>/<page>`). No server change is needed.

## How it works (non-obvious parts)

- A `.zul` without `<html>` root is answered as a **full HTML document**. `<head>` lists every script/CSS the ZK client
and the page need (URLs are versioned, e.g. `/zkau/web/57aacf5b/js/zk.wpd`); `<body>` has the widgets plus one inline
`<script class="z-runonce">zk.afterLoad(function(){zkmx([0,'..',{dt:'<desktopId>',...}, ...])})</script>`.
- `mountInline` parses that document, injects the `<head>` assets into the host (scripts strictly in order, deduplicated
per document by absolute URL, shared between concurrent embeds), appends the body **re-creating its `<script>` nodes**
(scripts created by `innerHTML`/`importNode` never run), then waits until the desktop announced by the response
appears in `zk.Desktop.all`.
- The desktop id is read from the response's own `dt:'...'` (JS-unescaped: ZK writes `-` as `\-` in the source). Do not
attribute desktops by diffing `zk.Desktop.all`: concurrent mounts steal each other's desktops.
- `destroy()` detaches the desktop's root pages (so floating popups go away), calls `zAu._rmDesktop(desktop, false)`
(same `rmDesktop` beacon ZK sends on page unload; the server then treats that desktop as unknown) and deletes it from
`zk.Desktop.all`. Each `hx-get`/embed is its own request, hence its own `NavigationManagerSession` scope and its own
`ZKNavigationManager` (see `NAVIGATION_SESSION.md` in `docs/backend/`).
- The custom element renders the content in its **light DOM** (slotted), not in the shadow root: ZK needs the host
document's global scope (ids, CSS, popups appended to `<body>`).

## Constraints

- **Same origin only.** Cross-origin URLs are rejected (`mountInline` throws before any request). Use the iframe mode of
`<dynamia-embed>` or the official `zEmbedded` (zkmax, PE/EE) for those.
- Depends on **private ZK client API** (`zAu._rmDesktop`, `zk.Desktop.all`). Every access is guarded: an unexpected ZK
version degrades to "no server-side cleanup" (desktop lingers until the session expires), not to an exception. Verified
with ZK 10.3.0.1.
- Scope it to **view fragments** (viewers, CRUD, components). Page navigation (`setPageLater`) from inside an inline
fragment has no workspace to open pages in.
- ZK's global CSS (`zk.wcs`, `zk-bootstrap.css`) is injected into the host and can affect its styles; host styles can
affect ZK widgets. Not isolated (ZK-5061 documents the same for `zEmbedded`).
- One WebSocket per ZK page (`dynamia-tools-ws.js`): behavior with several simultaneous desktops in one window is not
validated.
- Inline `<script>` inside the response `<head>` is ignored (only `<script src>` and stylesheets are injected).

## Verified

Unit tests (`ui-core/test/embed/inline.test.ts`, happy-dom) and a headless-Chrome run against `examples/demo-zk-books`:
two concurrent inline embeds (`/books` and `/page-embed/library/books`) each get their own desktop; removing one leaves
the other working; changing `src` replaces the desktop; cross-origin is rejected. The Vue component is type-checked and
built, not exercised in a browser yet.
5 changes: 5 additions & 0 deletions docs/frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ This folder is organized to help frontend developers understand:
- **Contains:** wire protocol, stateless `resumeToken` design, `FlowRemoteAction` Java API, frontend rollout
plan — and calls out that confirm/toast/dialog primitives don't exist yet in `ui-core`/`vue`

#### 5. **[Inline ZK Embed](./INLINE_ZK_EMBED.md)**
- **Purpose:** Embed server-rendered ZK views into a Vue/JS host without an iframe (same origin only)
- **For:** Frontend developers integrating ZK pages into a Vue shell
- **Contains:** `<DynamiaZkEmbed>` / `<dynamia-embed mode="inline">` usage, how the mount works, constraints

---

## 🎯 How to Use This Documentation
Expand Down
1 change: 1 addition & 0 deletions platform/packages/ui-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"devDependencies": {
"@dynamia-tools/sdk": "workspace:*",
"@types/node": "^26.4.1",
"happy-dom": "^20.14.5",
"typescript": "^6.0.3",
"vite": "^8.2.2",
"vite-plugin-dts": "^5.1.0",
Expand Down
50 changes: 49 additions & 1 deletion platform/packages/ui-core/src/embed/DynamiaEmbed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// consume it only via the `@dynamia-tools/ui-core/embed` subpath.

import { detectEmbedType } from './detectType.js';
import { mountInline, type InlineHandle } from './inline.js';

const DEFAULT_TIMEOUT_MS = 8000;
const DEFAULT_SANDBOX = 'allow-scripts';
Expand All @@ -31,9 +32,13 @@ const CUSTOM_ELEMENT_NAME_RE = /^[a-z][a-z0-9._-]*-[a-z0-9._-]*$/;
* - `timeout` — milliseconds before the `HEAD` probe / JS import is abandoned. Defaults to 8000.
* - `loading` — `"lazy" | "eager"`, passed through to the iframe. Defaults to `"lazy"`.
* - `no-resize` — boolean attribute; disables the `ResizeObserver` + postMessage auto-resize path.
* - `mode="inline"` — same-origin, iframe-less embed of a server-rendered ZK view/page (see `inline.ts`).
* Skips type detection; the content is mounted in the element's light DOM (slotted), NOT inside the
* shadow root, because the ZK client engine needs the host document's global scope. `sandbox`,
* `height`, `loading` and `no-resize` do not apply.
*
* Events (bubble, cross shadow boundary):
* - `dynamia-embed:load` — `{ type, src }`
* - `dynamia-embed:load` — `{ type, src }` (`type` is `'html' | 'js' | 'inline'`; inline adds `desktopIds`)
* - `dynamia-embed:error` — `{ src, error }`
*
* Example:
Expand All @@ -53,6 +58,7 @@ export class DynamiaEmbed extends HTMLElement {
private _loadToken = 0;
private _resizeObserver: ResizeObserver | null = null;
private _messageListener: ((event: MessageEvent) => void) | null = null;
private _inline: { handle: InlineHandle | null; host: HTMLElement; abort: AbortController } | null = null;

constructor() {
super();
Expand All @@ -65,6 +71,7 @@ export class DynamiaEmbed extends HTMLElement {

disconnectedCallback(): void {
this._cancelPending();
this._teardownInline();
}

attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
Expand Down Expand Up @@ -94,7 +101,14 @@ export class DynamiaEmbed extends HTMLElement {

private async _load(src: string): Promise<void> {
this._cancelPending();
this._teardownInline();
const token = ++this._loadToken;

if (this.getAttribute('mode') === 'inline') {
await this._loadInline(src, token);
return;
}

this._renderLoading();

try {
Expand All @@ -119,6 +133,40 @@ export class DynamiaEmbed extends HTMLElement {
}
}

// ── Inline (same-origin ZK, no iframe) ──────────────────────────────────────────────

private async _loadInline(src: string, token: number): Promise<void> {
const host = document.createElement('div');
host.setAttribute('data-dynamia-embed-inline', '');
const abort = new AbortController();
this._inline = { handle: null, host, abort };
this._shadow.replaceChildren(document.createElement('slot'));
this.replaceChildren(host);

try {
const handle = await mountInline(host, src, { timeoutMs: this._timeoutMs, signal: abort.signal });
if (token !== this._loadToken || this._inline?.host !== host) {
handle.destroy();
return;
}
this._inline.handle = handle;
this._dispatch('dynamia-embed:load', { type: 'inline', src, desktopIds: handle.desktopIds });
} catch (error) {
if (token !== this._loadToken || abort.signal.aborted) return;
this._renderError('Failed to load embed');
this._dispatch('dynamia-embed:error', { src, error });
}
}

private _teardownInline(): void {
const inline = this._inline;
if (!inline) return;
this._inline = null;
inline.abort.abort();
inline.handle?.destroy();
inline.host.remove();
}

// ── HTML → sandboxed iframe ──────────────────────────────────────────────

private _loadIframe(src: string): void {
Expand Down
12 changes: 12 additions & 0 deletions platform/packages/ui-core/src/embed/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ import { DynamiaEmbed, detectEmbedType } from '../../packages/ui-core/src/embed/

Only `src` is reactive; the others are read once per load. Call `.reload()` on the element after changing them to apply.

### Inline mode (`mode="inline"`, same-origin ZK, no iframe)

```html
<dynamia-embed mode="inline" src="/page-embed/library/books"></dynamia-embed>
```

Skips type detection and mounts a same-origin, server-rendered ZK view directly in the page (light DOM, no iframe,
no sandbox). `sandbox`, `height`, `loading` and `no-resize` do not apply; `timeout` does. The `dynamia-embed:load`
event carries `type: 'inline'` and `desktopIds`. Removing the element (or changing `src`) releases the ZK desktop.
Cross-origin URLs fail with `dynamia-embed:error`. Programmatic API: `mountInline(container, src)` from
`@dynamia-tools/ui-core/embed`. Details, constraints and how it works: [docs/frontend/INLINE_ZK_EMBED.md](../../../../docs/frontend/INLINE_ZK_EMBED.md).

## Events

Both bubble and cross the shadow boundary (`composed: true`):
Expand Down
2 changes: 2 additions & 0 deletions platform/packages/ui-core/src/embed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@

export { DynamiaEmbed } from './DynamiaEmbed.js';
export { registerDynamiaEmbed } from './register.js';
export { mountInline } from './inline.js';
export type { InlineHandle, InlineMountOptions } from './inline.js';
export { detectEmbedType } from './detectType.js';
export type { EmbedContentType, DetectTypeOptions } from './detectType.js';
Loading
Loading