# Extension webviews

> Show a sandboxed web page from an extension in a panel or a screen, and let it call the extension through RPC.

A webview is a web page an extension shows inside the app: in a side panel tab, or in a screen. The page is your own HTML, CSS and scripts, in a sandboxed frame. It cannot reach the network, the app or the disk. It talks to the extension through one bridge: calls to the RPC contracts the item allows.

Use a webview for a full custom UI that the [data items](https://zidane.si/docs/extensions-ui.md) and the kit [components](https://zidane.si/docs/extensions-components.md) cannot draw: a canvas, an editor, a page you already have. It costs more than a component: it does not follow the app's theme, and the terminal app cannot show it.

## A minimal example

The kitchen sink adds a page from its folder in a side panel tab (`examples/extension-kitchen-sink/ui-items.ts`):

```ts
import { Rpc, Webview } from 'zidane'
import { z } from 'zod'

const KitchenRpc = Rpc.define({
  id: 'kitchen-sink',
  methods: {
    count: { output: z.number() },
    bump: { input: z.object({ by: z.number().int().min(1) }), output: z.object({ count: z.number() }), errors: { 'too-big': z.object({ limit: z.number() }) } },
  },
})

ctx.add(Webview, { id: 'page', title: 'Kitchen page', src: { file: 'webview/index.html' }, placement: 'panel', rpc: [KitchenRpc.id] })
```

The page posts calls to its parent window, and gets the results back (`webview/page.js`):

```js title="webview/page.js"
let nextId = 1
const pending = new Map()

function call(method, input) {
  const id = nextId++
  window.parent.postMessage({ type: 'zidane:rpc', id, contract: 'kitchen-sink', method, input }, '*')
  return new Promise((resolve, reject) => pending.set(id, { resolve, reject }))
}

window.addEventListener('message', (event) => {
  const data = event.data
  if (!data || data.type !== 'zidane:rpc:result' || !pending.has(data.id))
    return
  const { resolve, reject } = pending.get(data.id)
  pending.delete(data.id)
  if (data.ok)
    resolve(data.output)
  else
    reject(data.error)
})

call('count').then((count) => {
  document.getElementById('count').textContent = String(count)
})
```

```html title="webview/index.html"
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <link rel="stylesheet" href="page.css">
  </head>
  <body>
    <p>Counter: <strong id="count">…</strong></p>
    <script src="page.js"></script>
  </body>
</html>
```

The extension implements the contract with `ctx.rpc.register` as usual ([RPC](https://zidane.si/docs/extensions-rpc.md)). The page is one more caller.

## The item

| Field | Type | Does |
| - | - | - |
| `id` | `string` | Required. Unique per extension |
| `title` | `Bindable<string>` | Required. The tab label, or the screen title |
| `src` | `{ file: string } \| { url: string }` | Required. A file in a subfolder of the extension's folder, relative, without `..`. Or an `http(s)://` URL |
| `placement` | `'panel' \| 'screen'` | Required. A side panel tab, or a screen opened with `ctx.ui.navigate({ screen: id })` |
| `rpc` | `string[]` | Ids of the RPC contracts the page may call. Default: none |

A `screen` webview opens only through navigation, like any [screen](https://zidane.si/docs/extensions-placements.md#screens-and-modals):

```ts
import { Webview } from 'zidane'

ctx.add(Webview, { id: 'editor', title: 'Recipe editor', src: { file: 'webview/editor.html' }, placement: 'screen', rpc: ['kitchen-sink'] })
ctx.commands.register({ id: 'edit-recipes', title: 'Edit recipes', run: () => void ctx.ui.navigate({ screen: 'editor' }) })
```

## The bridge

The page sends a message to `window.parent`, and the app answers with a message to the page.

| Direction | Message |
| - | - |
| Page to app | `{ type: 'zidane:rpc', id, contract, method, input? }`. `id` is a string or a number of your choice, echoed in the answer |
| App to page, success | `{ type: 'zidane:rpc:result', id, ok: true, output }` |
| App to page, failure | `{ type: 'zidane:rpc:result', id, ok: false, error: { type, message, data? } }` |

The app checks that a message comes from the page's own frame, and ignores any other. When the app runs the extension itself, a call runs as the extension's UI: the implementation sees `caller.kind === 'ui'`. Through the daemon or a server, it runs as a client of the window's connection: a local daemon does not limit it, and on a server a method above the window's token [scope](https://zidane.si/docs/extensions-rpc.md#declare-an-rpc-contract) answers `rpc.forbidden`. Inputs and outputs are validated as for any caller.

`error.type` is one of:

| `type` | Means |
| - | - |
| A declared error name (`too-big`) | The method returned or threw `call.error(name, …)`. `data` holds its data |
| `rpc.not-allowed` | The contract is not in the item's `rpc` list. The call never reached the extension |
| `rpc.unavailable` | No extension implements the contract, or the app has no RPC |
| `rpc.invalid-input`, `rpc.invalid-output` | The input or the output failed the contract's schema |
| `rpc.internal` | The method threw something else |

Calls that are still running end when the page goes away.

## The sandbox

The frame runs with `sandbox="allow-scripts allow-forms"` and without `allow-same-origin`: the page has an opaque origin, no cookies, no storage shared with the app. A page from the extension's folder is served with a content security policy:

| Directive | Allows |
| - | - |
| `default-src` | Nothing |
| `script-src`, `style-src` | Files from the page's own folder, and inline code |
| `img-src` | The page's folder, `data:` and `blob:` URLs |
| `font-src`, `media-src` | The page's folder (fonts also `data:` in the web app, media also `blob:`) |
| `connect-src` | Nothing: no `fetch`, no WebSocket |
| `form-action` | Nothing |

Only files in the folder of the `src` file are served. Put the page and its assets in one folder, as the kitchen sink's `webview/`. For data, call the extension: it can fetch, read files and use its integrations.

A `url` source loads that page in the same sandboxed frame. The page's own server sets its policy, and the bridge works the same way.

## In each app

| | Terminal app | Desktop app | Web app |
| - | - | - | - |
| `placement: 'panel'` | Not shown | A side panel tab, with a browser icon | A side panel tab |
| `placement: 'screen'` | A box with the title and the URL. `Enter` opens a `url` source in the system browser. A `file` source says to open it in the desktop app | A large dialog with the page | A large dialog with the page |
| `file` source | Not shown | Served by the app from the extension's folder | Served by the server, at a URL that needs a token and expires after 12 hours |
| `url` source | Opened in the browser | `http(s)` only | As given |

After the web app reconnects to a restarted server, it asks for new page URLs, so open webviews load again.

## Pitfalls

- Allow each contract in `rpc`. A page that calls an unlisted contract gets `rpc.not-allowed`, even for the extension's own contract.
- `fetch` fails in a page from the extension's folder. Put network calls in the extension, behind an RPC method.
- `file://` URLs do not show in the desktop app. Use a `file` source.
- `localStorage` and cookies do not work in the sandbox. Keep state in a [store](https://zidane.si/docs/extensions-storage.md#typed-stores) and read it through RPC.
- The page does not follow the app's theme. If it must match, prefer a [component](https://zidane.si/docs/extensions-components.md).
- `zidane extension pack` checks that each `file` source exists inside the folder. Run it before you publish ([publishing](https://zidane.si/docs/extensions-publishing.md)).
