# Custom Object Viewer

> [!NOTE]
>
> **Available in lakeFS Team and lakeFS Enterprise. [Start a free trial](<https://lakefs.io/try/>) or [contact us](<https://lakefs.io/book-a-demo/>).**

A custom object viewer is a web page that lakeFS embeds when a user opens a matching object. Use one to display a file type lakeFS does not render, or to replace a built-in viewer with your own. Viewers match objects by extension or content type; see [Matching rules](<#matching-rules>).

> [!NOTE]
>
> The messaging protocol is experimental and may change between releases.

## Prerequisites

Before you start, you need:

- lakeFS Enterprise version 1.93.0 or higher
- Permission to edit the [lakeFS server configuration](<https://docs.lakefs.io/reference/enterprise-configuration/index.md>), where you register the viewer.
- Permission to restart lakeFS, which loads the new configuration.
- Somewhere to host a static HTML page, reachable from each user's browser and meeting the [hosting requirements](<#hosting-requirements>).

The walkthrough below serves the page locally with the HTTP server built into Python 3, but any static HTTP server works.

## Building a text viewer

### 1\. Create the page

Save this as `index.html`:

index.html

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Text viewer</title>
  </head>
  <body>
    <pre id="output"></pre>

    <script>
      window.addEventListener("message", (event) => {
        // Ignore messages that are not from the embedding lakeFS page.
        if (event.source !== window.parent) return;
        if (event.data?.type !== "object-response") return;

        const text = new TextDecoder().decode(event.data.content);
        document.getElementById("output").textContent = text;

        const height = document.documentElement.scrollHeight + "px";
        window.parent.postMessage({ type: "set-iframe-height", height }, "*");
      });

      // Register the listener before telling lakeFS that the viewer is ready.
      window.parent.postMessage({ type: "viewer-ready" }, "*");
    </script>
  </body>
</html>
```

After the viewer sends [`viewer-ready`](<#viewer-ready>), lakeFS returns the object in [`object-response`](<#object-response>). The example decodes its content as UTF-8 and resizes the frame to fit the text.

### 2\. Serve the page

Run this command from the directory containing `index.html`:

```sh
python3 -m http.server 8080
```

The page is served at `http://localhost:8080/`. Alternatively, copy `index.html` to any static file server and use that URL.

### 3\. Register the viewer

Add the viewer to [`ui.custom_viewers`](<https://docs.lakefs.io/reference/enterprise-configuration/#ui>) in the lakeFS server configuration, setting `url` to the address where you serve the page:

```yaml
ui:
  custom_viewers:
    - name: Text viewer
      url: http://localhost:8080/
      extensions:
        - txt
      content_types:
        - text/plain
```

Restart lakeFS to load the new configuration.

### 4\. Test the viewer

Upload a file named `hello.txt` containing known text, then open it from the repository's objects page. Its contents should appear in the custom viewer.

If the built-in viewer opens or the frame stays blank, see [Troubleshooting](<#troubleshooting>).

Before making the viewer available to other users, review [Hosting requirements](<#hosting-requirements>) and [Security](<#security>).

## Displaying an image

Replace the `<script>` element in the [text viewer](<#1-create-the-page>) with this script. It creates a `Blob` from the `ArrayBuffer` and displays it through an object URL:

```javascript
window.addEventListener("message", (event) => {
  if (event.source !== window.parent) return;
  if (event.data?.type !== "object-response") return;

  const blob = new Blob([event.data.content], { type: event.data.contentType });
  const image = document.createElement("img");
  image.style.maxWidth = "100%";
  image.onload = () => {
    const height = document.documentElement.scrollHeight + "px";
    window.parent.postMessage({ type: "set-iframe-height", height }, "*");
  };
  image.src = URL.createObjectURL(blob);
  document.body.appendChild(image);
});

window.parent.postMessage({ type: "viewer-ready" }, "*");
```

Update the viewer configuration to match the image's content type or extension.

## Reading part of a large object

By default, lakeFS sends the entire object. To read selected bytes instead, send [`viewer-ready`](<#viewer-ready>) with `metadataOnly: true`, then use [`range-request`](<#range-request>) messages.

Replace the `<script>` element in the [text viewer](<#1-create-the-page>) with this script. It shows the end of a large text object, like `tail`:

```javascript
const output = document.getElementById("output");

window.addEventListener("message", (event) => {
  if (event.source !== window.parent) return;

  switch (event.data?.type) {
    case "object-response": {
      const rangeSize = Math.min(1024, event.data.size);
      if (rangeSize > 0) {
        const start = event.data.size - rangeSize;
        const message = { type: "range-request", id: "tail", start, rangeSize };
        window.parent.postMessage(message, "*");
      }
      break;
    }
    case "range-response": {
      const text = new TextDecoder().decode(event.data.content);
      // A range starts mid-line, so drop the partial first line.
      output.textContent = text.slice(text.indexOf("\n") + 1);
      break;
    }
    case "range-error":
      output.textContent = event.data.error;
      break;
  }
});

window.parent.postMessage({ type: "viewer-ready", metadataOnly: true }, "*");
```

Give each request a unique `id` when you send more than one. Range reads suit formats such as Parquet, whose metadata sits in the file footer.

## How it works

lakeFS loads the page in a new sandboxed `iframe` each time a user opens an object. The page and lakeFS exchange object data through `window.postMessage`. The [protocol reference](<#protocol-reference>) documents every message.

### Matching rules

When a user opens an object, lakeFS picks a viewer in two steps, using the values configured in [`ui.custom_viewers`](<https://docs.lakefs.io/reference/enterprise-configuration/#ui>):

1. Lowercase the text after the last dot in the object's name, and look for a viewer that lists it in `extensions`.
2. If none matched, look for a viewer that lists the object's content type in `content_types`.

A content type or extension can belong to only one viewer. If no viewer matches, lakeFS falls back to its built-in rendering.

### Sandboxing

The frame carries [`sandbox="allow-scripts"`](<https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#sandbox>), so a viewer can run scripts but cannot read the cookies or browser storage of the host that serves it. The sandbox also gives the page an [opaque origin](<https://developer.mozilla.org/en-US/docs/Glossary/Origin>), so even requests to its own host are cross-origin.

## Hosting requirements

The viewer URL must be reachable from each user's browser. If the lakeFS UI uses HTTPS, serve the viewer over HTTPS too; browsers block an HTTP viewer inside an HTTPS page. `http://localhost` is exempt.

Any `Content-Security-Policy` or `X-Frame-Options` headers on the host must allow the lakeFS origin to embed the page.

### Allowing CORS on the viewer's host

Return `Access-Control-Allow-Origin: *` from the viewer's host if the viewer loads module scripts or fonts, or reads responses with `fetch` or `XMLHttpRequest`. Those requests are cross-origin because the viewer runs on an [opaque origin](<#sandboxing>). If you cannot change the host's [CORS](<https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS>) configuration, [relax the sandbox](<#relaxing-the-sandbox-with-allow_same_origin>) instead.

## Security

A viewer can run scripts and receives the full contents of every matching object a user opens, so register only viewers whose code and hosting you trust. It holds no lakeFS credentials and by default cannot read or change anything through the lakeFS API.

Since object content is user data, render it with safe DOM APIs such as `textContent` or an `<img>` element.

Do not navigate away after startup: lakeFS sends replies to the frame, so a page that replaces it can receive object data and request more.

### Relaxing the sandbox with `allow_same_origin`

With [`allow_same_origin: true`](<https://docs.lakefs.io/reference/enterprise-configuration/#ui>), the viewer keeps its real origin, so requests to its own host are no longer cross-origin and it can load resources without CORS. lakeFS refuses to load such a viewer from the lakeFS origin itself.

Prefer [CORS](<#allowing-cors-on-the-viewers-host>) where you can configure it on the viewer's host.

> [!WARNING]
>
> Host the viewer on its own domain, not a sibling subdomain of lakeFS: `viewer.example.com` and `lakefs.example.com` are the same site, so the viewer's requests to lakeFS could carry the user's session cookie.

## Troubleshooting

lakeFS shows viewer errors above the frame. It does not send these errors to the viewer.

- **The built-in viewer opens.** The object did not match a configured content type or extension; see [Matching rules](<#matching-rules>).
- **The frame stays blank.** Check the following:

  1. Open the viewer URL directly and confirm that it loads. It will not display object content outside lakeFS.
  2. Open the browser developer tools for the lakeFS tab and check the Console panel for blocked framing, an HTTP viewer inside an HTTPS page, or CORS errors; see [Hosting requirements](<#hosting-requirements>).
  3. Confirm that the viewer registers its message listener before sending [`viewer-ready`](<#viewer-ready>).
- **Changes do not appear.** Refresh the repository's objects page to load changes to the viewer page. Configuration changes need a lakeFS restart.

## Protocol reference

Every message is a plain object with a required `type` field. Each heading below gives that value; the code block under it shows the message's shape. Numeric fields must be finite safe integers.

lakeFS silently ignores messages whose data is not an object. Unknown message types and known types with invalid fields are reported only in the lakeFS UI, never to the viewer; see [Troubleshooting](<#troubleshooting>).

### Message flow

A typical exchange starts when the viewer sends [`viewer-ready`](<#viewer-ready>).

```
sequenceDiagram
    participant V as Viewer
    participant L as lakeFS
    L->>V: Load viewer page
    V->>L: viewer-ready { metadataOnly? }
    L->>V: object-response { content, contentType, size, path }
    opt Read a range
        V->>L: range-request { id?, start, rangeSize }
        alt Read succeeds
            L->>V: range-response { id, content, start }
        else Read fails
            L->>V: range-error { id, start, error }
        end
    end
    opt Set minimum height
        V->>L: set-iframe-height { height }
    end
```

Responses can arrive in a different order from their requests: a viewer that sends range requests immediately after `viewer-ready` can receive a [`range-response`](<#range-response>) before the [`object-response`](<#object-response>). Match range responses to range requests with `id`.

### Messages sent by the viewer

#### `viewer-ready`

```typescript
{
  type: "viewer-ready";
  metadataOnly?: boolean; // default false
}
```

Send this once, after registering the message listener. lakeFS replies with [`object-response`](<#object-response>) containing the entire object; there is no size limit, and the content is buffered in memory.

Set `metadataOnly: true` to skip the download and read the bytes you need with [range requests](<#range-request>), as shown in [Reading part of a large object](<#reading-part-of-a-large-object>).

#### `range-request`

```typescript
{
  type: "range-request";
  id?: unknown;      // echoed in the reply; use a unique value
  start: number;     // 0 or greater, below the object size
  rangeSize: number; // 1 or greater
}
```

Read `rangeSize` bytes from offset `start`. lakeFS replies with [`range-response`](<#range-response>), or with [`range-error`](<#range-error>) if the read fails. Requests with invalid values get no reply.

Omit `id` only when at most one request is outstanding; the reply's `id` is then `undefined`.

#### `set-iframe-height`

```typescript
{
  type: "set-iframe-height";
  height: string; // CSS length, such as "600px"
}
```

Set the frame's minimum height. Send again whenever the content height changes.

### Messages sent by lakeFS

#### `object-response`

```typescript
{
  type: "object-response";
  content: ArrayBuffer | null; // the whole object; null when metadataOnly was set
  contentType: string;         // content type from the object's metadata
  size: number;                // full object size in bytes
  path: string;                // object path in the repository
}
```

Sent in reply to [`viewer-ready`](<#viewer-ready>). `content` is `null` only when the viewer requested `metadataOnly`. If lakeFS cannot load the object, it reports the error in its UI and sends no response.

#### `range-response`

```typescript
{
  type: "range-response";
  id: unknown;          // id from the matching request
  content: ArrayBuffer; // bytes read from the object
  start: number;        // start from the matching request
}
```

Sent in reply to a successful [`range-request`](<#range-request>). If a valid range extends beyond the end of the object, `content` contains only the remaining bytes. You have reached the end when `start + content.byteLength` equals `size` from [`object-response`](<#object-response>).

#### `range-error`

```typescript
{
  type: "range-error";
  id: unknown;   // id from the matching request
  start: number; // start from the matching request
  error: string; // description of the failure
}
```

Sent when fetching a requested range fails.
