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 devThe runner will start at http://localhost:5173.
Using PNPM Workspace
From the repository root:
# Run runner from root
pnpm --filter @object-ui/runner devWhat'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:
| URL | Loader | Where metadata comes from |
|---|---|---|
http://localhost:5173/ | LocalBundleLoader | JSON bundled from src/app-data/ at build time |
http://localhost:5173/?api=/api | NetworkLoader | fetched 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 route | Request | Response |
|---|---|---|
| (any, once at startup) | GET <base>/app.json | the app document (AppComponentSchema) |
/ | GET <base>/pages/index.json | the page document (PageNodeSchema) |
/customers | GET <base>/pages/customers.json | the page document |
/crm/accounts | GET <base>/pages/crm/accounts.json | the 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=/apikeeps 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/apigoes cross-origin, which needs CORS on the backend. The loader callsfetchwith 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 showsPage not found: <path>for a page, and for a failedapp.jsonit 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
useMemowith 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 justapiโ 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 route | Files tried, in order |
|---|---|
/ | pages/index.json, pages/index/index.json, index.json |
/customers | pages/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'ssrcdirectory, which is what lets From Source bepnpm installthenpnpm dev, with nopnpm -w buildin between. It has to be the transitive closure, not just the Runner's own direct imports: any@object-ui/*imported anywhere under thesrcof a package that is already aliased needs its own entry too. A specifier that is missing falls back to Node resolution, lands onpackages/<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-kanbanwas aliased, thefieldsandplugin-detailthat it imports were not.build.modulePreload: falseโ it keeps the icon code-split from backfiring. The alias table is not scoped toserve, sopnpm buildbundles those packages from source as well, and the per-icon chunks thatcomponents/src/lib/lazy-icon.tsxcreates 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 defaultmodulePreload: trueemits a preload link for every one โindex.htmlmeasured 546 B โ 145 KB โ so the browser eagerly fetches all of them on first paint and the lazy split turns into a pessimisation.apps/consoledisables 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.)
- Depend on it in
packages/runner/package.json, through the workspace protocol, then re-runpnpm installfrom the repo root:
"dependencies": {
"@object-ui/plugin-yourplugin": "workspace:*"
}- Register it with a side-effect import in
src/App.tsx, alongside the two pre-installed plugins:
import '@object-ui/plugin-yourplugin'- Alias it in
packages/runner/vite.config.tsโ together with every@object-ui/*package your plugin's ownsrcimports, since that table has to stay the transitive closure. Skipping this is what thevite.config.tssection 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"),- Add a Tailwind
@sourceline insrc/index.css, so the classes your plugin's components use survive the CSS build:
@source '../../packages/plugin-yourplugin/src/**/*.{ts,tsx}';- 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 immediately3. 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:5173Schemas 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 devpnpm build
Build for production:
pnpm buildpnpm preview
Preview production build:
pnpm previewpnpm test
Run tests:
pnpm testPackage 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
- CLI - Use CLI for rapid development
- Plugins - Explore available plugins
- Schema Overview - Learn schema format
- Examples - See more examples
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 hereIf 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