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:
| Component | Repository | Description |
|---|---|---|
| Renderer SDK | upl-im-app/packages/@xeonr/renderer-sdk | Client-side SDK for renderers |
| Renderer Host | upl-im-app/packages/@xeonr/renderer-host | Host-side iframe embedding components |
| Vite Plugin | upl-im-app/packages/@xeonr/renderer-vite-plugin | Build tooling, archive packaging, deployment |
| Renderer Devtools | upl-im-app/packages/@xeonr/renderer-devtools | Interactive debugging UI |
| Renderer Proxy | upl-im-renderer-proxy | Go service serving archives via subdomain routing |
| CI Pipeline | ci/templates/renderer-pipeline.yml | GitLab CI template for automated deploys |
| Paste Renderer | upl-im-app/portal/paste-renderer | Example: read-only code viewer |
| Notes Renderer | notes-app/renderer | Example: 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
- Extract upload ID from the subdomain (e.g.
upl_abc123.renderer.upl.im) - Validate the JWT access token (HMAC-signed, contains
upload_idandbucket_id) - Fetch upload metadata from the API (validates
UPLOAD_TYPE_INTEGRATION_ARCHIVE) - Resolve the requested file within the archive (via
ARCHIVE_FILEmeta uploads indexed by filename) - Proxy the file content with security headers
Security headers
Sec-Fetch-Destvalidation — blocks non-iframe loadsContent-Security-Policy: default-src 'self'with specific allowancesX-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)
| Variable | Description |
|---|---|
JWT_SECRET | HMAC secret for token validation |
JWT_AUDIENCE | Expected audience claim (renderer-access) |
API_ENDPOINT | Internal API URL |
API_BUCKET_ID | Bucket where archives are stored |
SERVICE_ACCOUNT_TOKEN | Service account token for API auth |
IDP_URL | Identity provider URL |
CACHE_TTL_SECONDS | File URL cache TTL (default: 300) |
PORT | Listen 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→ addsallow-popups
Host hooks
Located in web/src/features/renderers/:
| Hook | Purpose |
|---|---|
useUploadRenderer | Resolves a renderer for a specific upload via resolveResourceIntegrations |
useIntegrationBucketRenderer | Resolves a renderer for a bucket type |
useVirtualFolderRenderer | Resolves a renderer for virtual files/folders |
fetchRendererConfig | Fetches config.json from the renderer proxy |
useRendererBridge
Internal hook in @xeonr/renderer-host that manages the postMessage protocol:
- Sends
uplim:initwhen iframe sendsuplim: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 notificationuplim:token— Token refresh response
Iframe → Host:
uplim:ready— Iframe loaded, ready for inituplim:ack— Processed init, ready to displayuplim:openUpload— Request to open upload in modaluplim:tokenRequest— Request fresh tokenuplim:close— Request modal/renderer closure
Vite plugin internals
Build pipeline
- configResolved — Validates
config.jsonagainst the schema (version, permissions whitelist, sandbox flags) - closeBundle — After Vite build completes:
- Copies
config.jsoninto the build output directory - Validates all entrypoints (
dashboard.html,portal.html) exist in output - Creates
.ziparchive from build output - If
UPLIM_RENDERER_DEPLOY_ENABLED=1, runs the deployment flow
- Copies
Deployment flow (5 steps)
- Create upload intent —
IntegrationAdminService/CreateIntegrationArchiveUploadIntentwithclientId - Request signed URL —
BucketUploadsService/RequestSignedUploadwith file size and mime type - Upload archive — HTTP PUT to the pre-signed URL
- Complete upload —
BucketUploadsService/CompleteSignedUpload - Update integration config — one of:
UpsertIntegrationBucketType(whenUPLIM_BUCKET_TYPE_SLUGis set)UpsertIntegrationContentType(whenUPLIM_CONTENT_TYPE_IDis set)
API base URLs by environment
| Environment | URL |
|---|---|
production | https://uploads-api.xeonr.io |
dev | https://uploads-api.xeonr.dev |
local | http://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: manualRequired CI/CD variables
Set these in your GitLab project's CI/CD settings (Settings → CI/CD → Variables):
| Variable | Scope | Description |
|---|---|---|
UPLIM_ACCESS_TOKEN_PROD | Production | OAuth token for production deployment |
UPLIM_ACCESS_TOKEN_STAGE | Staging | OAuth token for staging deployment |
UPLIM_CLIENT_ID | All | OAuth client ID for the integration |
UPLIM_INTEGRATION_ID | All | The integration's ID |
REGISTRIES_JSON | All | JSON array of private npm registries (optional) |
How the deploy job works
The .renderer-deploy template:
- Installs Node.js (version from
NODE_VERSION) andzip - Activates corepack/pnpm
- Configures private registries from
REGISTRIES_JSONif set - Runs
pnpm install --frozen-lockfile - Changes into
APP_DIR - Sets
UPLIM_ENVandUPLIM_ACCESS_TOKENbased onRENDERER_DEPLOY_ENV:"production"→ usesUPLIM_ACCESS_TOKEN_PROD, setsUPLIM_ENV=production, Vite modeproduction- Anything else → uses
UPLIM_ACCESS_TOKEN_STAGE, setsUPLIM_ENV=dev, Vite modedevelopment
- 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
postMessageexchanges
Common issues
| Symptom | Cause | Fix |
|---|---|---|
| Renderer shows loading indefinitely | uplim:ready not being sent | Ensure RendererClient is instantiated on page load |
| "Renderer timed out" in host | Renderer didn't send uplim:ack within 10s | Check for errors in the renderer's console |
| Token expired errors | Not handling token refresh | Use apiAdapter instead of manually managing tokens |
| CORS errors in dev | Dev server origin mismatch | The dev harness handles this — use it instead of embedding manually |
| Blank iframe in production | Archive missing entrypoint | Check that dashboard.html and portal.html are in the build output |
| CSP violations | Renderer loading external resources | External resources must be loaded via the renderer's own origin or data URIs |
Checklist for new renderers
-
config.jsonexists and passes validation - Both
dashboard.htmlandportal.htmlentrypoints exist - Renderer sends
uplim:readyon load anduplim:ackafter processing init - Loading state shown while
connectedis false - Theme synced from host (don't hardcode light/dark)
- Unsupported scope types handled gracefully
- API calls use
apiAdapterfor 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