Xeonr Developer Docs
Custom Renderers

Internal Guide

Internal details for building, deploying, and maintaining renderers within the xeonr organisation.

This page covers internal details for xeonr developers. Read the other renderer pages first for the public-facing API and concepts.

Repository layout

The renderer system spans several repositories:

ComponentRepositoryDescription
Renderer SDKupl-im-app/packages/@xeonr/renderer-sdkClient-side SDK for renderers
Renderer Hostupl-im-app/packages/@xeonr/renderer-hostHost-side iframe embedding components
Vite Pluginupl-im-app/packages/@xeonr/renderer-vite-pluginBuild tooling, archive packaging, deployment
Renderer Devtoolsupl-im-app/packages/@xeonr/renderer-devtoolsInteractive debugging UI
Renderer Proxyupl-im-renderer-proxyGo service serving archives via subdomain routing
CI Pipelineci/templates/renderer-pipeline.ymlGitLab CI template for automated deploys
Paste Rendererupl-im-app/portal/paste-rendererExample: read-only code viewer
Notes Renderernotes-app/rendererExample: collaborative rich-text editor

How the renderer proxy works

The renderer proxy (upl-im-renderer-proxy) is a Go service that serves renderer archives via subdomain-based routing:

https://{archiveUploadId}.renderer.upl.im/{filePath}?token={accessToken}

Request flow

  1. Extract upload ID from the subdomain (e.g. upl_abc123.renderer.upl.im)
  2. Validate the JWT access token (HMAC-signed, contains upload_id and bucket_id)
  3. Fetch upload metadata from the API (validates UPLOAD_TYPE_INTEGRATION_ARCHIVE)
  4. Resolve the requested file within the archive (via ARCHIVE_FILE meta uploads indexed by filename)
  5. Proxy the file content with security headers

Security headers

  • Sec-Fetch-Dest validation — blocks non-iframe loads
  • Content-Security-Policy: default-src 'self' with specific allowances
  • X-Frame-Options: ALLOWALL (safe within sandboxed subdomain context)

Allowed file extensions

.html, .css, .js, .json, .png, .jpg, .gif, .svg, .webp, .woff, .woff2, .bin

Configuration (environment variables)

VariableDescription
JWT_SECRETHMAC secret for token validation
JWT_AUDIENCEExpected audience claim (renderer-access)
API_ENDPOINTInternal API URL
API_BUCKET_IDBucket where archives are stored
SERVICE_ACCOUNT_TOKENService account token for API auth
IDP_URLIdentity provider URL
CACHE_TTL_SECONDSFile URL cache TTL (default: 300)
PORTListen port (default: 8080)

Host-side integration

The host application (upl-im-app/web) uses several hooks and components to embed renderers.

IframeSandbox component

@xeonr/renderer-host provides the IframeSandbox component:

<IframeSandbox
  rendererDomain="renderer.upl.im"
  archiveUploadId="upl_abc123"
  entrypoint="dashboard"
  scope={{ type: 'upload', bucketId: 'bkt_x', uploadId: 'upl_y' }}
  renderingType="preview-modal"
  rendererConfig={config}
  mode="modal"
  apiBaseUrl="https://uploads-api.xeonr.io"
  theme="dark"
  getToken={getTokenFn}
  onOpenUpload={handleOpenUpload}
  onClose={handleClose}
/>

Sandbox attributes

Default: allow-scripts allow-same-origin allow-forms

Optional (via rendererConfig.sandbox):

  • allowPopups → adds allow-popups

Host hooks

Located in web/src/features/renderers/:

HookPurpose
useUploadRendererResolves a renderer for a specific upload via resolveResourceIntegrations
useIntegrationBucketRendererResolves a renderer for a bucket type
useVirtualFolderRendererResolves a renderer for virtual files/folders
fetchRendererConfigFetches config.json from the renderer proxy

useRendererBridge

Internal hook in @xeonr/renderer-host that manages the postMessage protocol:

  • Sends uplim:init when iframe sends uplim:ready
  • Handles uplim:ack, uplim:tokenRequest, uplim:openUpload, uplim:close
  • Provides sendThemeUpdate() method
  • 10-second timeout for iframe readiness

Protocol messages

Host → Iframe:

  • uplim:init — Initial bootstrap with full payload (token, scope, theme, config)
  • uplim:theme — Theme change notification
  • uplim:token — Token refresh response

Iframe → Host:

  • uplim:ready — Iframe loaded, ready for init
  • uplim:ack — Processed init, ready to display
  • uplim:openUpload — Request to open upload in modal
  • uplim:tokenRequest — Request fresh token
  • uplim:close — Request modal/renderer closure

Vite plugin internals

Build pipeline

  1. configResolved — Validates config.json against the schema (version, permissions whitelist, sandbox flags)
  2. closeBundle — After Vite build completes:
    • Copies config.json into the build output directory
    • Validates all entrypoints (dashboard.html, portal.html) exist in output
    • Creates .zip archive from build output
    • If UPLIM_RENDERER_DEPLOY_ENABLED=1, runs the deployment flow

Deployment flow (5 steps)

  1. Create upload intentIntegrationAdminService/CreateIntegrationArchiveUploadIntent with clientId
  2. Request signed URLBucketUploadsService/RequestSignedUpload with file size and mime type
  3. Upload archive — HTTP PUT to the pre-signed URL
  4. Complete uploadBucketUploadsService/CompleteSignedUpload
  5. Update integration config — one of:
    • UpsertIntegrationBucketType (when UPLIM_BUCKET_TYPE_SLUG is set)
    • UpsertIntegrationContentType (when UPLIM_CONTENT_TYPE_ID is set)

API base URLs by environment

EnvironmentURL
productionhttps://uploads-api.xeonr.io
devhttps://uploads-api.xeonr.dev
localhttp://localhost:4003

Dev harness

During vite dev, the plugin serves an interactive harness at the dev server root. It provides:

  • Entrypoint selector (dashboard/portal)
  • Scope preset selector with editable JSON
  • Rendering type selector
  • Token and API URL configuration
  • Live postMessage log
  • Theme toggle
  • Connection status indicator

Setting up xeonr-ci for a renderer

The CI pipeline is defined in ci/templates/renderer-pipeline.yml. It provides two hidden job templates that your pipeline extends.

Pipeline structure

# .gitlab-ci.yml (or generated child pipeline)

include:
  - project: 'xeonr/ci'
    ref: main
    file: '/templates/renderer-pipeline.yml'

variables:
  NODE_VERSION: "22"
  PNPM_VERSION: "10"

# Install dependencies (warms pnpm cache)
install:
  extends: .renderer-install

# Deploy to staging
deploy:staging:
  extends: .renderer-deploy
  variables:
    APP_DIR: "renderer"              # Path to renderer directory in repo
    RENDERER_DEPLOY_ENV: "dev"       # "dev" or "production"
    UPLIM_CLIENT_ID: $UPLIM_CLIENT_ID
    UPLIM_INTEGRATION_ID: $UPLIM_INTEGRATION_ID
    UPLIM_BUCKET_TYPE_SLUG: "my-bucket-type"  # OR use UPLIM_CONTENT_TYPE_ID
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

# Deploy to production
deploy:production:
  extends: .renderer-deploy
  variables:
    APP_DIR: "renderer"
    RENDERER_DEPLOY_ENV: "production"
    UPLIM_CLIENT_ID: $UPLIM_CLIENT_ID
    UPLIM_INTEGRATION_ID: $UPLIM_INTEGRATION_ID
    UPLIM_BUCKET_TYPE_SLUG: "my-bucket-type"
  rules:
    - if: $CI_COMMIT_TAG
  when: manual

Required CI/CD variables

Set these in your GitLab project's CI/CD settings (Settings → CI/CD → Variables):

VariableScopeDescription
UPLIM_ACCESS_TOKEN_PRODProductionOAuth token for production deployment
UPLIM_ACCESS_TOKEN_STAGEStagingOAuth token for staging deployment
UPLIM_CLIENT_IDAllOAuth client ID for the integration
UPLIM_INTEGRATION_IDAllThe integration's ID
REGISTRIES_JSONAllJSON array of private npm registries (optional)

How the deploy job works

The .renderer-deploy template:

  1. Installs Node.js (version from NODE_VERSION) and zip
  2. Activates corepack/pnpm
  3. Configures private registries from REGISTRIES_JSON if set
  4. Runs pnpm install --frozen-lockfile
  5. Changes into APP_DIR
  6. Sets UPLIM_ENV and UPLIM_ACCESS_TOKEN based on RENDERER_DEPLOY_ENV:
    • "production" → uses UPLIM_ACCESS_TOKEN_PROD, sets UPLIM_ENV=production, Vite mode production
    • Anything else → uses UPLIM_ACCESS_TOKEN_STAGE, sets UPLIM_ENV=dev, Vite mode development
  7. Runs pnpm exec vite build --mode ${VITE_MODE} — the Vite plugin handles archive creation and deployment

Private registry configuration

If your renderer depends on packages from a private npm registry (e.g. @xeonr/* packages from the internal registry), set the REGISTRIES_JSON CI/CD variable:

[
  {
    "scope": "@xeonr",
    "url": "https://registry.internal.xeonr.dev",
    "token_var": "NPM_REGISTRY_TOKEN"
  }
]

The pipeline's before_script will generate .npmrc entries automatically.

Sharing code with a parent application

The notes-app renderer demonstrates how to share components with a parent application. Use Vite aliases to import from outside the renderer directory:

// vite.config.ts
const parentRoot = resolve(__dirname, '..');

export default defineConfig({
  resolve: {
    alias: {
      '~': resolve(parentRoot, 'src'),
    },
    dedupe: ['react', 'react-dom', 'yjs', 'styled-components'],
  },
  server: {
    fs: {
      allow: [parentRoot],  // Required for dev server
    },
  },
});

This lets your renderer import shared components (import { Editor } from '~/components/editor/Editor') while building them into the renderer's archive.

Important: deduplicate shared dependencies to avoid multiple React instances or CRDT conflicts.

Debugging

Renderer devtools

The @xeonr/renderer-devtools package provides an interactive debugging panel with:

  • Left panel: Configure renderer URL, entrypoint, scope, rendering type, tokens
  • Centre: Live iframe showing the renderer
  • Right panel: Message log showing all postMessage exchanges

Common issues

SymptomCauseFix
Renderer shows loading indefinitelyuplim:ready not being sentEnsure RendererClient is instantiated on page load
"Renderer timed out" in hostRenderer didn't send uplim:ack within 10sCheck for errors in the renderer's console
Token expired errorsNot handling token refreshUse apiAdapter instead of manually managing tokens
CORS errors in devDev server origin mismatchThe dev harness handles this — use it instead of embedding manually
Blank iframe in productionArchive missing entrypointCheck that dashboard.html and portal.html are in the build output
CSP violationsRenderer loading external resourcesExternal resources must be loaded via the renderer's own origin or data URIs

Checklist for new renderers

  • config.json exists and passes validation
  • Both dashboard.html and portal.html entrypoints exist
  • Renderer sends uplim:ready on load and uplim:ack after processing init
  • Loading state shown while connected is false
  • Theme synced from host (don't hardcode light/dark)
  • Unsupported scope types handled gracefully
  • API calls use apiAdapter for authentication
  • Archive size is reasonable (tree-shake, lazy-load heavy deps)
  • body { background: transparent } set
  • CI pipeline configured with correct variables
  • Tested in both dashboard and portal entrypoints
  • Tested in both light and dark themes

On this page