Skip to content
Draft
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
9 changes: 5 additions & 4 deletions typescript/catalogs/iframe/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

## Unreleased

- Add the `@a2ui/catalog-iframe` package: the sandbox proxy asset with its configuration
(`configureSandbox`, `resolveSandboxUrl`), the `FrameHost` interface and the `WebAppFrameBridge`
host bridge with allowlist validation, data model sync, function calls and sizing.
[#2797](https://github.com/a2ui-project/a2ui/pull/2797)
- Add the `@a2ui/catalog-iframe` package: `iframeCatalog` with the `WebAppFrameUrl` and
`WebAppFrameSrcdoc` universal components, which render a web app inside the sandbox proxy and
connect it to the surface through `WebAppFrameBridge`. Hosts serve the sandbox asset and locate
it with `configureSandbox`. [#2797](https://github.com/a2ui-project/a2ui/pull/2797)
[#2798](https://github.com/a2ui-project/a2ui/pull/2798)
182 changes: 170 additions & 12 deletions typescript/catalogs/iframe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,136 @@ application or an external web app, inside a sandboxed frame that talks to the h
the `a2ui_*` message protocol defined in the
[WebApp iframe component specification](../../../catalogs/iframe/web_app_frame_specification.md).

This release contains the building blocks the frame components are made of:
The package contains:

- `iframeCatalog`, with the two universal components of the catalog, `WebAppFrameUrl` and
`WebAppFrameSrcdoc`;
- the sandbox proxy asset (`sandbox/` in the published package) and the configuration of where the
host serves it;
- `WebAppFrameBridge`, the host side of the protocol: `MessagePort` handshake, two-way data model
sync for `data.paths`, `allowedEvents`, `allowedFunctions` and `mutableData` allowlists with JSON
Schema validation, function calls, resize requests and host context updates;
- the zod schemas of the wire messages and the `FrameHost` interface a renderer adapts its component
context to.

The `WebAppFrameUrl` and `WebAppFrameSrcdoc` components that use them are added in a following
release, together with the catalog registration.
- `SandboxedFrameElement` and `ComponentContextFrameHost`, the base class and the host adapter the
components are built on, for catalogs that add their own sandboxed frame components.

## Installation

```bash
npm install @a2ui/catalog-iframe
npm install @a2ui/catalog-iframe @a2ui/web_core lit
```

`@a2ui/web_core` and `lit` are peer dependencies: the components are universal components built on
`A2uiLitElement` from `@a2ui/web_core/v1_0`. The package also depends on `zod` and `ajv`
and expects a browser environment.

## Usage

The components are custom elements that any renderer able to host universal components can render.
Register the catalog with the `MessageProcessor` next to the catalogs you already use, and serve the
sandbox asset (next section):

```ts
import {basicCatalog, MessageProcessor} from '@a2ui/web_core/v1_0';
import {iframeCatalog} from '@a2ui/catalog-iframe';

const processor = new MessageProcessor([basicCatalog, iframeCatalog]);
```

A surface resolves components through its default catalog, the `catalogId` of its
`createSurface` message, unless a component names another registered catalog with its own
`catalogId`. A payload that mixes frames with basic components therefore creates the surface with
the basic catalog id and marks the frame component:

```json
{
"id": "counter_app",
"component": "WebAppFrameSrcdoc",
"catalogId": "https://a2ui.org/specification/v1_0/catalogs/iframe/catalog.json",
"height": 160,
"htmlContent": "..."
}
```

The package depends on `zod` and `ajv` and expects a browser environment.
A frame's `allowedFunctions` resolve against the frame component's own catalog first and then
the surface default, so a frame on such a surface can call the basic catalog's functions.

## Components

Both components render one `<iframe>` in their light DOM that loads the sandbox proxy; the proxy
runs the content in an inner frame with `sandbox="allow-scripts allow-forms allow-modals"`, never
`allow-same-origin`. The proxy frame is titled with the component's `accessibility.label` when it
has one, or "Embedded web application". They share these properties:

| Property | Type | Purpose |
| ------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `height` | `DynamicNumber` | Height of the frame in CSS pixels. Without it, the element is 500px tall; see Styling. |
| `config` | object | Static configuration handed to the application in the `a2ui_app_frame_init` handshake. |
| `data.paths` | map of key to JSON pointer | Data model paths the application can read; their values are sent initially and whenever they change. |
| `mutableData` | map of key to JSON Schema | The subset of `data.paths` keys the application may write, each with the schema its value must satisfy. |
| `allowedEvents` | map of action name to JSON Schema | Actions the application may dispatch, each with the schema of its payload. Anything else is dropped. |
| `allowedFunctions` | map of function name to JSON Schema | Catalog functions the application may call, each with the schema of its arguments. |
| `disableSchemaValidation` | boolean | Skips the JSON Schema checks; the allowlists still apply. For trusted first-party applications only. |
| `accessibility` | `AccessibilityAttributes` | `label` becomes the accessible name of the frame. |

`WebAppFrameSrcdoc` adds `htmlContent`, the HTML string to render, optionally prefixed with
`url_encoded:` when it was `encodeURIComponent`-encoded for transport. The host injects a strict
`Content-Security-Policy` meta tag into the markup (no network, no form submission, no nested
frames; an author policy is kept and can only narrow it) and a script that turns hyperlink clicks
into `open_url` actions instead of navigations. List `open_url` in `allowedEvents` to receive them.
An empty value loads nothing.

`WebAppFrameUrl` adds `url`, a `DynamicString` with the `http` or `https` URL of the application.
The host appends its own origin as the `origin` query parameter so the application can address its
messages to it; other schemes load nothing and are reported once with `console.warn`.

A message that renders a counter app sharing `/counter/count` with the surface (markup shortened):

```json
{
"version": "v1.0",
"updateComponents": {
"surfaceId": "shared-counter",
"components": [
{
"id": "counter_app",
"component": "WebAppFrameSrcdoc",
"height": 160,
"data": {"paths": {"count": "/counter/count", "label": "/counter/label"}},
"mutableData": {"count": {"type": "integer", "minimum": 0}},
"allowedEvents": {
"counter_saved": {
"type": "object",
"properties": {"count": {"type": "integer"}},
"required": ["count"],
"additionalProperties": false
}
},
"htmlContent": "<!doctype html><html><body><p id=\"status\"></p><script>...</script></body></html>"
}
]
}
}
```

The application inside receives `{count: 0, label: "clicks"}` in the handshake, writes `count` back
through `a2ui_data_model_change`, and dispatches `counter_saved`, which reaches the host as an
action of the `counter_app` component. Property changes are applied live: a new `htmlContent` or
`url` reloads the content and reconnects the bridge, a new `height` resizes the element.

### Styling

The components are styled through CSS custom properties, which can be set on any ancestor:

| Property | Default | Purpose |
| -------------------------------------- | -------------------------------------------------------------------- | -------------------------------------- |
| `--a2ui-sandboxed-frame-height` | `500px` | Height used when `height` is absent. |
| `--a2ui-sandboxed-frame-border` | `var(--a2ui-border-width, 1px) solid var(--a2ui-color-border, #ccc)` | Border around the frame. |
| `--a2ui-sandboxed-frame-border-radius` | `var(--a2ui-border-radius, 8px)` | Corner radius. |
| `--a2ui-sandboxed-frame-background` | `#fff` | Background behind transparent content. |

An application that asks for a size through `a2ui_size_changed` resizes the frame and the element
within the limits of the bridge (100px to 2000px high); the next `height` property change takes
over again.

## Sandbox asset

Expand Down Expand Up @@ -91,10 +201,11 @@ at it and list the host origin in the proxy page, which then keeps the self-test

## Host bridge

`WebAppFrameBridge` connects one frame to the surface that renders it. It takes the frame element,
a `FrameHost` (five functions: read, write and subscribe to a data model path, invoke a catalog
function, dispatch an action), the origin the proxy is served from, and a function that returns the
component's current properties:
The components own their bridge; this section is for hosts that embed a frame without the
components. `WebAppFrameBridge` connects one frame to the surface that renders it. It takes the
frame element, a `FrameHost` (five functions: read, write and subscribe to a data model path,
invoke a catalog function, dispatch an action), the origin the proxy is served from, and a function
that returns the component's current properties:

```ts
import {resolveSandboxUrl, sendSandboxResourceReady, WebAppFrameBridge} from '@a2ui/catalog-iframe';
Expand Down Expand Up @@ -128,6 +239,53 @@ against the wire schema, matched against the allowlist of its kind and validated
Schema listed there, in that order. Anything that fails is dropped with a `console.warn`; function
calls get an error reply (`NOT_ALLOWED`, `VALIDATION_ERROR` or `EXECUTION_ERROR`).

## Writing another sandboxed frame component

`SandboxedFrameElement` is the base class of both components. It renders the proxy frame, applies
the `height` property, listens for the proxy's ready signal, sends the resource once the proxy is
listening, reconnects the bridge when the context or the content changes, and tears everything down
when the element leaves the document. A subclass provides the proxy page and protocol to use, the
resource its properties describe, its height and title, and the bridge connection:

```ts
import {
ComponentContextFrameHost,
frameHeightFromProp,
frameTitleFromProps,
SandboxedFrameElement,
} from '@a2ui/catalog-iframe';

class MyFrameElement extends SandboxedFrameElement<typeof MyFrameApi> {
protected override readonly api = MyFrameApi;
protected readonly sandboxMode = 'html' as const;
protected readonly sandboxProtocol = 'a2ui' as const;

protected resolveResource() {
return {html: this.controller.props.markup};
}
protected resolveHeight() {
return frameHeightFromProp(this.controller.props.height);
}
protected resolveTitle() {
return frameTitleFromProps(this.controller.props.accessibility, 'My frame');
}
protected connectFrame(frame: HTMLIFrameElement, sandboxOrigin: string) {
const bridge = new MyBridge({
frame,
sandboxOrigin,
host: new ComponentContextFrameHost(this.context),
});
bridge.start();
return () => bridge.dispose();
}
}
```

`ComponentContextFrameHost` is the `FrameHost` over a universal component's `ComponentContext`:
data paths go through the component's data context, functions through the surface catalog, and
actions are dispatched as events of the component. Bridges that do not apply resize requests
themselves can call the protected `requestFrameSize(width, height)` of the base class.

## Development

The package lives in the [A2UI monorepo](https://github.com/a2ui-project/a2ui) under
Expand Down
12 changes: 8 additions & 4 deletions typescript/catalogs/iframe/karma.conf.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,21 @@ module.exports = function (config) {
plugins: [require('karma-jasmine'), require('karma-chrome-launcher'), require('karma-esbuild')],
files: [
{pattern: 'src/**/*.test.ts', watched: true},
// The real sandbox proxy, built into dist/sandbox/ by `build-sandbox`. It is served, not
// included in the test bundle.
{pattern: 'tests/**/*.test.ts', watched: true},
// The real sandbox proxy, built into dist/sandbox/ by `build-sandbox`, and the fixture
// pages of the real-frame scenarios. Both are served, not included in the test bundle.
{pattern: 'dist/sandbox/**', included: false, served: true, watched: false},
{pattern: 'tests/fixtures/**', included: false, served: true, watched: false},
],
// Serves the proxy at its default path, so the tests load it the way a host application
// would.
// Serves the proxy at its default path and the fixtures next to it, so the tests load them
// the way a host application would.
proxies: {
'/a2ui-sandbox/': '/base/dist/sandbox/',
'/a2ui-fixtures/': '/base/tests/fixtures/',
},
preprocessors: {
'src/**/*.test.ts': ['esbuild'],
'tests/**/*.test.ts': ['esbuild'],
},
esbuild: {
tsconfig: './tsconfig.test.json',
Expand Down
5 changes: 5 additions & 0 deletions typescript/catalogs/iframe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@
"files": [
"src/**/*.ts",
"src/**/*.json",
"tests/**",
"../../../catalogs/iframe/examples/*.json",
"tsconfig.json",
"tsconfig.test.json"
],
Expand All @@ -132,6 +134,8 @@
"files": [
"src/**/*.ts",
"src/**/*.json",
"tests/**",
"../../../catalogs/iframe/examples/*.json",
"karma.conf.cjs",
"tsconfig.test.json"
],
Expand All @@ -147,6 +151,7 @@
"files": [
"src/**/*.ts",
"src/**/*.json",
"tests/**",
"scripts/**",
"*.mjs",
"*.cjs",
Expand Down
70 changes: 64 additions & 6 deletions typescript/catalogs/iframe/src/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,73 @@
* limitations under the License.
*/

import {
basicCatalog,
isWebComponentImplementation,
MessageProcessor,
type WebComponentImplementation,
} from '@a2ui/web_core/v1_0';
import catalogJson from './catalog.json' with {type: 'json'};
import {IFRAME_CATALOG_ID} from './catalog.js';
import {IFRAME_CATALOG_ID, iframeCatalog} from './catalog.js';
import {A2uiWebAppFrameSrcdoc} from './components/web_app_frame_srcdoc.js';
import {A2uiWebAppFrameUrl} from './components/web_app_frame_url.js';
import {parseExampleMessages} from './testing/frame_test_support.js';

describe('IFRAME_CATALOG_ID', () => {
it('is the $id of the bundled catalog schema', () => {
expect(IFRAME_CATALOG_ID).toBe(catalogJson['$id']);
describe('iframeCatalog', () => {
it('is identified by the $id of the catalog schema', () => {
expect(iframeCatalog.id).toBe(IFRAME_CATALOG_ID);
expect(iframeCatalog.id).toBe(catalogJson['$id']);
});

it('names the catalog of the two frame components', () => {
expect(Object.keys(catalogJson['components'])).toEqual(['WebAppFrameUrl', 'WebAppFrameSrcdoc']);
it('implements every component of the catalog schema, and nothing else', () => {
expect([...iframeCatalog.components.keys()]).toEqual(Object.keys(catalogJson['components']));
expect(iframeCatalog.components.get('WebAppFrameUrl')).toBe(A2uiWebAppFrameUrl);
expect(iframeCatalog.components.get('WebAppFrameSrcdoc')).toBe(A2uiWebAppFrameSrcdoc);
expect(iframeCatalog.functions.size).toBe(0);
});

it('registers universal components with a2ui- prefixed tag names', () => {
for (const component of iframeCatalog.components.values()) {
expect(isWebComponentImplementation(component)).withContext(component.name).toBeTrue();
expect(component.tagName)
.withContext(component.name)
.toMatch(/^a2ui-[a-z-]+$/);
}
});

it('renders a frame that names the iframe catalog on a basic catalog surface', () => {
const processor = new MessageProcessor<WebComponentImplementation>([
basicCatalog,
iframeCatalog,
]);

processor.processMessages(
parseExampleMessages({
messages: [
{version: 'v1.0', createSurface: {surfaceId: 'frames', catalogId: basicCatalog.id}},
{
version: 'v1.0',
updateComponents: {
surfaceId: 'frames',
components: [
{
id: 'app',
component: 'WebAppFrameSrcdoc',
catalogId: IFRAME_CATALOG_ID,
htmlContent: '<p>App</p>',
},
],
},
},
],
}),
);

const surface = processor.model.getSurface('frames')!;
expect(surface.defaultCatalog).toBe(basicCatalog);
const frame = surface.componentsModel.get('app')!;
expect(frame.type).toBe('WebAppFrameSrcdoc');
expect(frame.catalog).toBe(iframeCatalog);
surface.dispose();
});
});
15 changes: 15 additions & 0 deletions typescript/catalogs/iframe/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,20 @@
* limitations under the License.
*/

import {Catalog, type WebComponentImplementation} from '@a2ui/web_core/v1_0';
import {A2uiWebAppFrameSrcdoc} from './components/web_app_frame_srcdoc.js';
import {A2uiWebAppFrameUrl} from './components/web_app_frame_url.js';

/** `$id` of the iframe catalog schema (`catalogs/iframe/catalog.json`). */
export const IFRAME_CATALOG_ID = 'https://a2ui.org/specification/v1_0/catalogs/iframe/catalog.json';

/**
* The iframe catalog: `WebAppFrameUrl` and `WebAppFrameSrcdoc`, ready to hand to a
* `MessageProcessor`. A surface that mixes them with other components needs one catalog holding
* every component it uses; build it with `new Catalog(id, '1.0', [...])` from this catalog's
* `components` and the others'.
*/
export const iframeCatalog = new Catalog<WebComponentImplementation>(IFRAME_CATALOG_ID, '1.0', [
A2uiWebAppFrameUrl,
A2uiWebAppFrameSrcdoc,
]);
Loading
Loading