ObjectUIObjectUI

Runner

Universal runtime for ObjectUI applications

ObjectUI Runner

The @object-ui/runner package is a universal runtime application for ObjectUI. It serves as a standalone demo environment, plugin testing playground, and reference implementation for building ObjectUI applications.

Overview

The Runner is a complete ObjectUI application that demonstrates best practices and provides a development environment for testing plugins and features.

Features

  • ๐ŸŽฎ Standalone Application - Complete ObjectUI app
  • ๐Ÿงช Plugin Testing - Test plugins in isolation
  • ๐ŸŽจ Example Implementations - Reference code
  • ๐Ÿš€ Development Playground - Experiment with schemas
  • ๐Ÿ“ฆ Pre-configured - The Kanban and Charts plugins, wired up out of the box

Running the Runner

From Source

# Clone the repository
git clone https://github.com/objectstack-ai/objectui.git
cd objectui

# Install dependencies
pnpm install

# Run the runner
cd packages/runner
pnpm dev

The runner will start at http://localhost:5173.

Using PNPM Workspace

From the repository root:

# Run runner from root
pnpm --filter @object-ui/runner dev

What's Included

Pre-installed Plugins

The Runner includes these plugins by default:

  • @object-ui/plugin-kanban - Kanban board with drag-and-drop
  • @object-ui/plugin-charts - Data visualization charts

Metadata โ€” Supplied by You

The Runner ships no metadata of its own: the package contains no .json schema files, so a fresh checkout renders nothing until you give it something to render. The two ways to do that are covered under Metadata Loading โ€” drop JSON into src/app-data/ (git-ignored, yours to create), or point the Runner at a backend with ?api=<base>. With neither in place, / shows the built-in No index page found. placeholder.

Development Tools

  • Hot Module Reloading - Instant updates
  • Error Overlay - Debug errors easily
  • React DevTools - Inspect component tree
  • Network Inspector - Monitor API calls

Where the Code Lives

packages/runner/src is small and holds no metadata. main.tsx mounts the root component; App.tsx is that root โ€” it picks the metadata loader from the api query parameter, imports the pre-installed plugins, handles routing, and hands the loaded page to <SchemaRenderer>. lib/MetadataLoader.ts holds both loaders (LocalBundleLoader and NetworkLoader), and LayoutRenderer.tsx draws the app chrome around the page. Everything you actually see rendered comes from @object-ui/react, @object-ui/components and the @object-ui/plugin-* packages, not from this one.

The one directory worth knowing about is src/app-data/ โ€” the metadata the Runner renders. It is git-ignored and absent from a fresh checkout; you create it. See Metadata Loading for its layout and resolution order.

Configuration

Metadata Loading

The Runner picks one of two metadata loaders when it mounts, from the api query parameter of the page URL (src/App.tsx). This is the Runner's only API base URL setting โ€” it reads no environment variables and no config file:

URLLoaderWhere metadata comes from
http://localhost:5173/LocalBundleLoaderJSON bundled from src/app-data/ at build time
http://localhost:5173/?api=/apiNetworkLoaderfetched from the base URL you passed

The branch is params.get('api'), so an empty value (?api=) is falsy and still selects the local loader. Which strategy won is logged to the browser console โ€” ๐Ÿ“ฆ Using Local Bundle Loader or ๐Ÿ”Œ Using Network Loader: <base>.

Serving metadata over HTTP (?api=<base>)

NetworkLoader uses the parameter value verbatim as a base URL and appends fixed paths to it, so a backend needs to serve exactly two kinds of JSON document:

Runner routeRequestResponse
(any, once at startup)GET <base>/app.jsonthe app document (AppComponentSchema)
/GET <base>/pages/index.jsonthe page document (PageNodeSchema)
/customersGET <base>/pages/customers.jsonthe page document
/crm/accountsGET <base>/pages/crm/accounts.jsonthe page document

Nested routes map straight through โ€” the request path is <base>/pages plus the browser path plus .json, with / rewritten to /index first.

  • Relative or absolute base. ?api=/api keeps the requests same-origin (pair it with a dev-server proxy or a reverse proxy in front of the built bundle); ?api=https://metadata.example.com/api goes cross-origin, which needs CORS on the backend. The loader calls fetch with no second argument, so it sends no credentials and no custom headers โ€” an endpoint behind cookie or bearer auth will not work as-is.
  • Failures are silent. Any non-2xx status, or a network/parse error, is turned into null. The Runner then shows Page not found: <path> for a page, and for a failed app.json it drops the app chrome (header/sidebar) and renders the page on its own. The HTTP status never reaches the UI โ€” read it from the Network tab.
  • Read once, at mount โ€” and it survives navigation. The parameter is captured in a useMemo with an empty dependency list, so editing ?api=โ€ฆ in the address bar does nothing to the running session until you reload. In-app navigation pushes the target path with the current query string carried over, so ?api=โ€ฆ stays in the address bar after a sidebar click, and reloading or sharing that URL reaches the same backend. The whole query string rides along, not just api โ€” that also keeps @object-ui/core's ?__debugโ€ฆ flags alive across navigation. A navigation target that spells out its own query keeps it and wins on collision; the remaining current parameters are merged in behind it.
  • NetworkLoader's own default base is /api (constructor(baseUrl: string = '/api')). That default only applies when the class is constructed directly in code โ€” the Runner always passes it the query-parameter value.

Running from bundled JSON (no api parameter)

LocalBundleLoader resolves metadata from packages/runner/src/app-data/ through Vite's import.meta.glob โ€” at build time, not over the network. It globs app-data/app.json, app-data/pages/**/*.json and app-data/*.json, and resolves a route by trying, in order:

Runner routeFiles tried, in order
/pages/index.json, pages/index/index.json, index.json
/customerspages/customers.json, pages/customers/index.json

src/app-data/ is git-ignored (see packages/runner/.gitignore) and is not part of a fresh checkout โ€” you supply it, by copying or symlinking your own metadata directory there. With that directory absent, the three globs compile to {}, every load returns null, and / renders the built-in No index page found. placeholder.

vite.config.ts

The Runner's Vite config is packages/runner/vite.config.ts. It is deliberately not reproduced on this page: the two parts of it that matter are hard invariants, and a copy here would be the first thing to drift out of step with them. Read the file โ€” its own comments carry the reasoning, and a shell command that re-derives the alias closure described below.

What it does not contain is a server block. The http://localhost:5173 used throughout this page is Vite's own default port, not a configured one, and nothing tells the dev server to open a browser โ€” pnpm dev prints the URL and waits.

The two things in that file you must not drop when you edit it:

  • resolve.alias โ€” this is why the Runner boots from a plain checkout. The table maps @object-ui/* specifiers onto each package's src directory, which is what lets From Source be pnpm install then pnpm dev, with no pnpm -w build in between. It has to be the transitive closure, not just the Runner's own direct imports: any @object-ui/* imported anywhere under the src of a package that is already aliased needs its own entry too. A specifier that is missing falls back to Node resolution, lands on packages/<pkg>/dist โ€” which does not exist in an install-only checkout โ€” and the dev server then answers HTTP 500 for every module on that import chain. That is #3575: plugin-kanban was aliased, the fields and plugin-detail that it imports were not.
  • build.modulePreload: false โ€” it keeps the icon code-split from backfiring. The alias table is not scoped to serve, so pnpm build bundles those packages from source as well, and the per-icon chunks that components/src/lib/lazy-icon.tsx creates stop being inlined. Measured when the table was completed (#3575), the build went from 10 assets to 1776, about 1761 of them sub-2KB icon micro-chunks. Vite's default modulePreload: true emits a preload link for every one โ€” index.html measured 546 B โ†’ 145 KB โ€” so the browser eagerly fetches all of them on first paint and the lazy split turns into a pessimisation. apps/console disables it for the same reason.

โ›” Do not paste a Vite config out of this page, or any other guide, over that file. Anything that arrives without the alias table reproduces #3575 exactly.

Adding Custom Plugins

There is no runtime plugin installation โ€” a plugin reaches the Runner only by editing the Runner's own sources. Steps 1-4 below are the four wiring points plugin-kanban and plugin-charts already have, so those two are a working reference for each of them.

Your plugin needs a place in this pnpm workspace first. pnpm-workspace.yaml globs packages/*, so a package created at packages/plugin-yourplugin is picked up by pnpm install from the repo root. (npm link is not the tool here โ€” this repo is a strict pnpm workspace.)

  1. Depend on it in packages/runner/package.json, through the workspace protocol, then re-run pnpm install from the repo root:
"dependencies": {
  "@object-ui/plugin-yourplugin": "workspace:*"
}
  1. Register it with a side-effect import in src/App.tsx, alongside the two pre-installed plugins:
import '@object-ui/plugin-yourplugin'
  1. Alias it in packages/runner/vite.config.ts โ€” together with every @object-ui/* package your plugin's own src imports, since that table has to stay the transitive closure. Skipping this is what the vite.config.ts section above describes: the dev server answers HTTP 500 for every module on the import chain.
"@object-ui/plugin-yourplugin": path.resolve(__dirname, "../../packages/plugin-yourplugin/src"),
  1. Add a Tailwind @source line in src/index.css, so the classes your plugin's components use survive the CSS build:
@source '../../packages/plugin-yourplugin/src/**/*.{ts,tsx}';
  1. Use in schemas:
{
  "type": "your-component"
  // Your component's keys go here, on the node itself โ€” renderers read
  // `schema.<key>`, not a `props` envelope
}

Use Cases

1. Plugin Development

Test new plugins in a full application context:

// src/App.tsx
import '@object-ui/plugin-myplugin'

// src/app-data/pages/index.json โ€” the page served at "/"
{
  "type": "page",
  "title": "Plugin test",
  "body": [
    { "type": "my-component", "message": "Testing my plugin" }
  ]
}

2. Schema Prototyping

Experiment with complex schemas:

# Edit the JSON under src/app-data/
# Changes are reflected immediately

3. Integration Testing

Test how multiple plugins work together:

{
  "type": "div",
  "children": [
    { "type": "kanban", "..." },
    { "type": "bar-chart", "..." },
    { "type": "data-table", "..." }
  ]
}

4. Demo & Presentation

Use as a live demo environment:

# Start runner for presentation
pnpm dev --host

# Access from any device on network
# http://your-ip:5173

Schemas to Start From

These live here in the docs, not in the package โ€” copy one, wrap it in a page document ({ "type": "page", "body": [ โ€ฆ ] }), and save it as src/app-data/pages/index.json, or serve it from your own backend and load it with ?api=<base>.

Dashboard Example

{
  "type": "div",
  "className": "p-8 space-y-6",
  "children": [
    {
      "type": "h1",
      "children": "Sales Dashboard"
    },
    {
      "type": "div",
      "className": "grid grid-cols-3 gap-4",
      "children": [
        {
          "type": "card",
          "title": "Revenue",
          "value": "$125,000",
          "change": "+12.5%"
        },
        {
          "type": "card",
          "title": "Orders",
          "value": "1,234",
          "change": "+8.2%"
        },
        {
          "type": "card",
          "title": "Customers",
          "value": "567",
          "change": "+5.1%"
        }
      ]
    },
    {
      "type": "bar-chart",
      "data": [
        { "month": "Jan", "sales": 4000 },
        { "month": "Feb", "sales": 3000 },
        { "month": "Mar", "sales": 6000 }
      ],
      "dataKey": "sales",
      "xAxisKey": "month",
      "height": 300
    }
  ]
}

Kanban Example

{
  "type": "kanban",
  "columns": [
    {
      "id": "todo",
      "title": "To Do",
      "cards": [
        {
          "id": "1",
          "title": "Design homepage",
          "description": "Create wireframes"
        }
      ]
    },
    {
      "id": "in-progress",
      "title": "In Progress",
      "cards": []
    },
    {
      "id": "done",
      "title": "Done",
      "cards": []
    }
  ]
}

Development Scripts

pnpm dev

Start development server with HMR:

pnpm dev

pnpm build

Build for production:

pnpm build

pnpm preview

Preview production build:

pnpm preview

pnpm test

Run tests:

pnpm test

Package Information

Package Name: @object-ui/runner โ€” published on npm, see the npm page for the current version
Type: Application, not a library. package.json declares no main, module, exports or types, so there is nothing to import from it โ€” you run it from a checkout of this repository, as shown above.
License: MIT

Dependencies

Key dependencies:

  • @object-ui/react - Core renderer
  • @object-ui/components - UI components
  • @object-ui/plugin-kanban - Kanban plugin
  • @object-ui/plugin-charts - Charts plugin
  • React - UI library
  • Vite - Build tool

Extending the Runner

Add Custom Routes

There is no route table to edit and no router dependency to install โ€” the Runner derives routes from metadata. src/App.tsx keeps the current path in state, navigates with history.pushState, and asks the active loader for whatever page answers that path. So adding a route means adding the page document it resolves to: see Add Custom Schemas below, and Metadata Loading for how a route maps to a file or a request.

Add Custom Components

The package ships no components of its own, so create the file wherever you like under src/ โ€” this example uses a components/ directory you add yourself:

// src/components/MyComponent.tsx
export const MyComponent = () => {
  return <div>My Custom Component</div>
}

Register with the ComponentRegistry:

import { ComponentRegistry } from '@object-ui/core'
import { MyComponent } from './components/MyComponent'

ComponentRegistry.register('my-component', MyComponent)

Add Custom Schemas

Add a page document to src/app-data/pages/. The file name is the route: this one is served at /my-page.

// src/app-data/pages/my-page.json
{
  "type": "page",
  "title": "My Page",
  "body": [
    {
      "type": "div",
      "className": "p-8",
      "children": [
        {
          "type": "my-component"
        }
      ]
    }
  ]
}

Best Practices

1. One File per Page

The loader globs src/app-data/, so a page is a file and nested routes are nested directories โ€” no index or barrel file to maintain:

src/app-data/
โ”œโ”€โ”€ app.json                 # app document: branding, navigation
โ””โ”€โ”€ pages/
    โ”œโ”€โ”€ index.json           # route "/"
    โ”œโ”€โ”€ customers.json       # route "/customers"
    โ””โ”€โ”€ crm/
        โ””โ”€โ”€ accounts.json    # route "/crm/accounts"

2. Error Boundaries

SchemaRenderer already wraps each rendered component in a SchemaErrorBoundary of its own, so a single widget that throws degrades to an inline "failed to render" notice with a Retry button instead of blanking the page โ€” see Architecture Overview.

That inner boundary only exists once the component has been resolved. Everything SchemaRenderer does before that point โ€” evaluating visibleOn, disabled and the other dynamic expressions, then looking the component type up in the registry โ€” runs outside it, so a throw there propagates past the inner boundary. Wrap SchemaRenderer itself to catch that class of failure:

import { SchemaErrorBoundary } from '@object-ui/react'

<SchemaErrorBoundary>
  <SchemaRenderer schema={schema} />
</SchemaErrorBoundary>

Two optional props tune it: componentType names the component in the fallback text, and resetKey clears the error and remounts the subtree whenever its value changes.

Next Steps

Troubleshooting

Port Already in Use

Change port in vite.config.ts:

export default defineConfig({
  server: {
    port: 3000, // Use different port
  },
})

Plugin Not Loading

A plugin registers itself through the side-effect imports in src/App.tsx. main.tsx only mounts the root component and imports no plugin at all โ€” not even the two pre-installed ones โ€” so looking there can neither confirm nor rule anything out. Open src/App.tsx and check yours sits with the two known-good imports:

import '@object-ui/plugin-kanban';
import '@object-ui/plugin-charts';
import '@object-ui/plugin-yourplugin';  // yours belongs here

If the import is there and the page still shows an Unknown component type box, the type in your schema and the key the plugin registers do not match โ€” compare the two. If instead the dev server returns HTTP 500 for the plugin's modules, its vite.config.ts alias entry is missing; see Adding Custom Plugins for the full set of wiring points a plugin needs.

Build Errors

Clear cache and rebuild:

rm -rf node_modules dist .vite
pnpm install
pnpm build

Need Help?

On this page