Skip to main content

Shell API

View as Markdown

Shell API

A complete guide for developers building micro-frontend applications for the RocketRide Cloud platform.


Table of Contents

  1. Architecture Overview
  2. Getting Started
  3. The App Manifest
  4. The App Descriptor
  5. Shell Props: What Your App Receives
  6. Screen Zones
  7. Shell Hooks & APIs
  8. The Connection Manager (connectionManager)
  9. The Documents System
  10. The Virtual File System (IVirtualFileSystem)
  11. DocExplorer: File Tree Component
  12. DocTabs: Tab Bar Component
  13. Cross-App Component Loading
  14. Theming
  15. Build Configuration

Architecture Overview

Shell-UI is a thin shell framework that hosts micro-frontend applications via Module Federation. The shell owns:

  • Four screen zones: Sidebar, Client Area, Debug Panel (ALT+D), Status Bar
  • Authentication: OAuth2 PKCE via Zitadel
  • Subscription gating: Checks server-side subscription state before loading paid apps
  • WebSocket connection: RocketRide client singleton
  • Workspace persistence: Per-app state in .workspace/ JSON files
  • Theme management: CSS custom properties (--rr-*)
  • Event bus: Typed message bus for cross-component communication

Apps are independent packages that export an AppDescriptor via Module Federation. The shell loads them lazily when the user activates the app.

┌──────────┬─────────────────────────┬────────────┐
│ │ │ │
│ Sidebar │ Client Area │ Debug │
│ │ │ (ALT+D) │
│ │ │ │
├──────────┴─────────────────────────┴────────────┤
│ ● Connected Ready │
└─────────────────────────────────────────────────┘

The shell mounts one component from your app:

  • app → Client Area (required). Your app composes its own layout inside (columns, sidebar, status bar) with <AppLayout>.

Getting Started

You build a shell app in your own repository: npm install rocketride, export an AppDescriptor through Module Federation, and deploy the bundle to any shell host.

Shortcut: the VS Code extension's App Builder scaffolds this whole shape from a template and adds a live preview, publish, and deploy loop — the steps below explain what it generates.

1. Create a new project

mkdir my-app && cd my-app
npm init -y
npm install rocketride react react-dom
npm install -D @rsbuild/core @rsbuild/plugin-react @module-federation/rsbuild-plugin typescript

2. Project structure

my-app/
├── package.json
├── rsbuild.config.ts
├── tsconfig.json
└── src/
├── index.ts # MF async boundary
├── AppDescriptor.ts # What the shell loads
└── MyApp.tsx # Client area component

3. Define the manifest in package.json

{
"name": "my-app",
"version": "1.0.0",
"appManifest": {
"id": "mycompany.myApp",
"publisher": "My Company",
"name": "My App",
"description": "A short description for the app store",
"categories": ["tools"]
},
"dependencies": {
"rocketride": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}

4. Create the AppDescriptor (src/AppDescriptor.ts)

Import types from rocketride/app-sdk:

import type { AppDescriptor } from 'rocketride/app-sdk';
import MyApp from './MyApp';

const MY_APP: AppDescriptor = {
id: 'mycompany.myApp',
name: 'My App',
branding: { appName: 'My App' },
app: MyApp,
};

export default MY_APP;

5. Create the App component (src/MyApp.tsx)

import React from 'react';
import type { ShellAppProps } from 'rocketride/app-sdk';

const MyApp: React.FC<ShellAppProps> = ({ isConnected, identity }) => {
return (
<div style={{ padding: 40, fontFamily: 'var(--rr-font-family)' }}>
<h1>Hello from My App!</h1>
<p>Connected: {isConnected ? 'Yes' : 'No'}</p>
<p>User: {identity?.displayName ?? 'Not logged in'}</p>
</div>
);
};

export default MyApp;

6. Async boundary (src/index.ts)

import('./AppDescriptor');

7. rsbuild.config.ts

import fs from 'node:fs';
import path from 'node:path';
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginModuleFederation } from '@module-federation/rsbuild-plugin';

const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
const moduleId = (pkg.appManifest?.id ?? 'unknown').replace(/[^a-zA-Z0-9_$]/g, '_');

export default defineConfig(() => ({
plugins: [
pluginReact(),
pluginModuleFederation({
name: moduleId,
filename: 'remoteEntry.js',
exposes: { './AppDescriptor': './src/AppDescriptor.ts' },
dts: false,
shared: {
react: { singleton: true, eager: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, eager: true, requiredVersion: '^18.2.0' },
'rocketride/app-sdk': { singleton: true, requiredVersion: false },
},
}),
],
source: { entry: { index: './src/index.ts' } },
output: {
distPath: { root: './dist' },
assetPrefix: 'auto',
},
}));

8. Build and deploy

npx rsbuild build

Deploy the contents of ./dist/ to your hosting provider. The shell loads your app's remoteEntry.js at runtime when it appears in the app manifest.


The App Manifest

Declared in package.json under the appManifest key. This metadata is available at boot without loading the app bundle, used for the app store, authentication gating, and settings.

interface AppManifest {
/** Stable unique identifier (e.g. 'rocketride.myApp'). */
id: string;
/** Publisher name shown in the app store (e.g. 'My Company'). */
publisher?: string;
/** Display name shown in the app switcher. */
name: string;
/** Short description for the app store. */
description?: string;
/** Path to icon file (e.g. './src/icon.svg'). */
icon?: string;
/** Markdown readme for the app detail page. */
readme?: string;
/** Categories for filtering (e.g. ['tools', 'ai']). */
categories?: string[];
/** When false, the app runs without authentication. Default: true. */
authenticated?: boolean;
/** When false, the shell header (app name/icon in the sidebar) is hidden. Default: true. */
showHeader?: boolean;
/** When false, the status bar is hidden for this app. Default: true. */
showStatusBar?: boolean;
/** Settings contribution (VSCode `contributes.configuration` shape). Shown in the shell's Settings overlay. */
contributes?: { configuration?: AppConfiguration };
/** Internal: MF module identifier (derived from id). */
moduleId?: string;
/** App lifecycle status (e.g. 'auth', 'free', 'unsubscribed', 'subscribed', 'trialing', 'past_due', 'canceled'). */
appStatus?: string;
/** Whether the app is available on the desktop (non-cloud) platform. */
onDesktop?: boolean;
}

Settings

Apps can declare runtime settings (API keys, config values) that the shell manages. The declaration lives under appManifest.contributes.configuration — the exact contributes.configuration shape from the VSCode extension manifest specification, with setting keys dotted and prefixed with the app id:

{
"appManifest": {
"contributes": {
"configuration": {
"title": "My App",
"properties": {
"mycompany.myApp.apiKey": {
"type": "string",
"description": "Your API key for the external service",
"required": true
}
}
}
}
}
}

Each property is a SettingSchema (type, default, description, enum, required, ...). Display labels derive from the setting key, VSCode-style — there is no label field (apiKey renders as "Api Key").

Settings are:

  • Rendered in the shell's Settings overlay (grouped by app)
  • Persisted to .workspace/settings.json
  • Available to your app via useShellApiConfig(), access as config['mycompany.myApp.apiKey']

The App Descriptor

The runtime descriptor your app exports. Loaded lazily when the user first activates your app.

interface AppDescriptor {
id: string; // Must match manifest id
name: string; // Display name
icon?: React.ReactNode; // Icon in app switcher
branding: ShellBrandingConfig; // Sidebar header branding

/** The app's ONE mount point — rendered raw in the client area. */
app: React.ComponentType<ShellAppProps>;

/** Optional cross-app component catalog. Never mounted by the shell. */
components?: {
[key: string]: React.ComponentType<any>;
};
}
  • app is the single component the shell mounts in the client area; the app composes its own layout inside (columns, sidebar, status bar) with <AppLayout>
  • components entries (e.g. Canvas, Toolbar, DataGrid) are never mounted by the shell but are loadable by other apps via useAppComponent()

ShellBrandingConfig

interface ShellBrandingConfig {
appName: string; // Sidebar header text
logo?: React.ReactNode; // Expanded sidebar logo
logoCollapsed?: React.ReactNode; // Collapsed sidebar logo
iconDark?: React.ReactNode; // Icon for dark palette (light-colored)
iconLight?: React.ReactNode; // Icon for light palette (dark-colored)
icon?: React.ReactNode; // Generic fallback icon
welcomeLogo?: React.ReactNode; // Welcome/loading screen logo
welcomeTitle?: string; // Welcome screen title
welcomeSubtitle?: string; // Welcome screen subtitle
}

Icon resolution order: the shell picks the best icon for the sidebar header:

  1. iconDark / iconLight (matched to the active palette mode)
  2. icon (generic branding icon)
  3. Manifest icon URL (from package.json)
  4. 2-letter monogram fallback

Pre-built theme-aware SVGs are available in shared/assets/rocketride/:

  • rocketride-dark.svg: light body (#E0DDF0) for dark backgrounds
  • rocketride-light.svg: dark body (#1E1A34) for light backgrounds
  • rocketride.svg: currentColor body, CSS-controlled

Shell Props: What Your App Receives

ShellAppProps (your App component)

interface ShellAppProps {
/** Whether the RocketRide WebSocket is currently connected. */
isConnected: boolean;
/** Authenticated user identity, or null when not logged in. */
identity: ConnectResult | null;
}

Screen Zones

ZoneOwnerContent
SidebarYour app component's layout (AppLayout)App switcher header, your sidebar content, theme/account/settings footer
Client AreaYour app componentWhatever your app renders
Debug PanelShell (ALT+D toggle)Live event log, postMessage traffic
Status BarShellConnection status, app name, ready state
OverlaysShellAccount, Billing, Settings (triggered from sidebar footer)

If your app's layout renders no sidebar, the client area gets the full window width.


Shell Hooks & APIs

rocketride/app-sdk is a subpath export of the rocketride package: at build time it gives your app types and IntelliSense, and at runtime Module Federation's shared singleton mechanism replaces its stubs with the real implementations from the shell host itself, published as the shell package, which is why in-workspace code generated by the app scaffold imports the same hooks directly from 'shell'.

Import everything from 'rocketride/app-sdk':

import { useShellConnection, useShellApiConfig, useWorkspace, connectionManager } from 'rocketride/app-sdk';

Connection

Hook/FunctionReturnsPurpose
useShellConnection(){ client, isConnected, statusMessage }Access the RocketRide WebSocket client
useShellApiConfig()ShellApiConfigRead runtime config (API URLs, keys, user settings)
getClient()RocketRideClient | nullNon-React access to the client singleton

Auth

HookReturnsPurpose
useAuthUser()ConnectResult | nullCurrent authenticated user identity
useLogout()() => voidTrigger logout
useSubscriptions(){ desktopApps, isOnDesktop(appId), getStatus(appId) }User's desktop apps and subscription state

Workspace

HookReturnsPurpose
useWorkspace()IWorkspaceContextAccess workspace state and dispatch

The workspace context provides:

  • seeded: True once pre-auth default state has been populated (before connection)
  • loaded: True once persisted workspace state has been read from disk (after connection)
  • prefs: Current app preferences (theme, active view, etc.)
  • appState: Opaque per-app state (used by Documents)
  • settings: User-configured settings
  • activeAppId: Current app ID
  • appManifest: All registered apps
  • appLoading: Whether the active app is currently loading
  • loadedApps: Map of already-loaded app descriptors (for cross-app component loading)
  • loadApp(appId): Trigger lazy loading of an app's descriptor
  • updateAppState(updater): Update per-app state — pass a function receiving the previous state and returning the next
  • updateSetting(key, value): Update a single setting
  • updatePrefs(patch): Update workspace preferences
  • themeOptions: Available theme choices
  • setTheme(themeId): Switch the active theme
  • dispatch(action): Update prefs or switch apps
  • emit(event, payload) / on(event, handler): Event bus (delegates to connectionManager)

Workspace Lifecycle: seeded vs loaded

The workspace has a two-phase startup:

  1. Seeded (seeded = true, loaded = false): The workspace has been populated with hardcoded defaults (default prefs, empty appState, empty settings). This happens immediately, before authentication or WebSocket connection. The Shell renders at this point so unauthenticated apps (e.g. home/landing page) can display.

  2. Loaded (seeded = true, loaded = true): The WebSocket is connected and persisted state has been read from disk (.workspace/global.json, per-app workspace files, settings.json). Persisted prefs, appState, and settings overwrite the seeded defaults. Debounced persistence (auto-save) only activates after this point.

What this means for your app:

  • The Shell renders as soon as seeded is true. It is then up to each app to decide whether it needs to wait for loaded.
  • Unauthenticated apps (authenticated: false) can render immediately on seeded state, they receive isConnected=false and identity=null and should be designed to work with those values.
  • Authenticated apps that depend on persisted settings (API keys, saved state) should gate on loaded before rendering data-dependent UI:
const { loaded, settings } = useWorkspace();

if (!loaded) return <div>Loading workspace…</div>;

// Safe to read persisted settings here
const apiKey = settings.MY_API_KEY;
  • Persistence is safe: debounced saves to disk only fire when loaded is true, so seeded defaults are never accidentally written over persisted data.

The Connection Manager (connectionManager)

A typed, module-level event bus singleton. Works from React components, hooks, plain functions, anywhere.

Basic usage

import { connectionManager } from 'rocketride/app-sdk';

// Emit an event
connectionManager.emit('shell:loginRequest', { appId: 'rocketride.myApp' });

// Subscribe to an event (returns unsubscribe function)
const unsub = connectionManager.on('shell:connected', () => {
console.log('Connected!');
});

// Later: unsubscribe
unsub();

In a React component

useEffect(() => {
const unsub = connectionManager.on('shell:event', ({ event }) => {
console.log('Server event:', event);
});
return unsub; // cleanup on unmount
}, []);

Defined events

EventPayloadDirectionDescription
shell:connected{}Shell → AppsWebSocket connection established
shell:disconnected{ reason: string; hasError: boolean }Shell → AppsWebSocket connection lost
shell:login{ user: ConnectResult }Shell → AppsUser authenticated
shell:logout{}Shell → AppsUser logged out
shell:loginRequest{ appId?: string }Apps → ShellRequest OAuth login (optionally targeting an app)
shell:logoutRequest{}Apps → ShellRequest logout
shell:switchApp{ appId: string }Apps → ShellSwitch the active app
shell:subscribe{ app: AppManifestEntry, plan?: CheckoutPlan }Apps → ShellOpen subscription checkout for an app; optional plan preselects a tier and skips the picker (straight to payment)
shell:myApps{}Apps → ShellNavigate to My Apps
shell:accountUpdateConnectResultServer → ShellServer-pushed account/subscription change
shell:sidebarCollapsing{}Shell → AppsSidebar is collapsing (for layout adjustments)
shell:themeChange{ tokens: Record<string, string> }Shell → AppsTheme CSS tokens changed
shell:statusChange{ message: string | null }Shell → AppsTransient status bar text changed
shell:event{ event: unknown }Server → AppsRaw server event forwarded from WebSocket
shell:appsUpdated{ apps: ShellAppEntry[] }Shell-internalApp list updated
shell:manifestRefresh{ source: string }Shell-internalApp manifest re-fetched
shell:openOverlay{ id: 'account' | 'settings' | 'environment' }Shell-internalOpen a shell overlay
shell:unsubscribe{ appId: string }Shell-internalCancel an app subscription
shell:viewActivated{ viewId: string }Shell-internalA shell view became active

The events marked Shell-internal exist only in the shell package's internal event map — they are not part of the ShellEventMap exported from rocketride/app-sdk, so external apps cannot emit or subscribe to them type-safely.

Extending the event map

Add custom events via TypeScript module augmentation:

declare module 'rocketride/app-sdk' {
interface ShellEventMap {
'myapp:dataUpdated': { recordId: string; timestamp: number };
'myapp:exportComplete': { fileUrl: string };
}
}

// Now type-safe:
connectionManager.emit('myapp:dataUpdated', { recordId: '123', timestamp: Date.now() });

Debug logging

All events are automatically captured in a circular buffer (500 entries) visible in the Debug Panel (ALT+D).

import { getDebugLog, clearDebugLog, onAny } from 'rocketride/app-sdk';

// Get all captured events
const log = getDebugLog(); // DebugLogEntry[]

// Listen to ALL events (for custom logging)
const unsub = onAny((event, payload) => {
console.log(`[${event}]`, payload);
});

The Documents System

A VS Code-style document model for apps that manage files/documents. Completely opt-in, simple apps don't need it.

Documents is an instantiable class: your app creates it, owns it, passes it where needed. The shell never sees it.

Core concepts

ConceptDescription
DocumentOne per URI. Holds content in memory. Tracks dirty state and version.
EditorA view onto a Document. Independent viewport state (scroll, cursor). Multiple editors can view the same document.
EditorGroupA pane container. Holds an ordered list of editors. Supports horizontal/vertical splits.

Creating an instance

Create a Documents instance in your App component, passing an IVirtualFileSystem:

// src/docs.ts — shared instance for your app
import { Documents } from 'rocketride/app-sdk';
import type { IVirtualFileSystem } from 'rocketride/app-sdk';

let _docs: Documents | null = null;

export function getDocs(): Documents {
if (!_docs) throw new Error('Documents not initialised');
return _docs;
}

export function createDocs(vfs: IVirtualFileSystem): Documents {
_docs = new Documents(vfs);
return _docs;
}

export function destroyDocs(): void {
_docs?.destroy();
_docs = null;
}
// src/MyApp.tsx
import { createDocs, destroyDocs } from './docs';

const MyApp: React.FC<ShellAppProps> = () => {
const { client } = useShellConnection();

useEffect(() => {
const vfs: IVirtualFileSystem = {
list: (dir) => client.fsListDir(dir),
read: (path) => client.fsReadJson(path),
write: (path, content) => client.fsWriteJson(path, content),
rename: (old, new_) => client.fsRename(old, new_),
delete: (path) => client.fsDelete(path),
mkdir: (path) => client.fsMkdir(path),
};
createDocs(vfs);
return () => destroyDocs();
}, [client]);

return <MyAppInner />;
};

Using the instance (call methods from anywhere)

import { getDocs } from './docs';

// Open a file
await getDocs().openDocument('path/to/file.txt');

// Create a new document with initial content
const uri = getDocs().createDocument(undefined, { key: 'value' });

// Update content (any serializable value — stored as-is)
getDocs().updateContent(uri, { key: 'updated' });

// Save to disk
await getDocs().saveDocument(uri);

All operations are methods on the Documents instance:

MethodDescription
openDocument(uri, groupId?)Open a file (reads from VFS if not already open)
createDocument(groupId?, content?)Create a new untitled document
closeEditor(editorId)Close an editor (disposes doc if last clean ref)
updateContent(uri, content)Update in-memory content (marks dirty)
saveDocument(uri)Write to disk via VFS (marks clean)
revertDocument(uri)Re-read from disk (replaces content)
splitGroup(groupId, orientation)Split an editor group
moveEditor(editorId, targetGroupId)Move editor between groups
closeGroup(groupId)Close all editors in a group
setActiveEditor(groupId, index)Activate an editor within a group
setActiveGroup(groupId)Focus a group
openStaticDocument(uri, label, content?, groupId?)*Open a read-only static document with a display label
splitGroupWithDocument(groupId, orientation)*Split a group, moving the active document to the new pane
updateSplitSizes(splitNodeId, sizes)*Update the sizes of a split layout node
updateEditorViewport(editorId, patch)Merge an editor's scroll position and cursor coordinates
updateEditorViewState(editorId, viewState)*Persist an editor's opaque view state (the app owns its shape)
getState()Read state without subscribing
getDocument(uri)Get a single document by URI
destroy()Clean up the instance

* Available on the shell package's Documents implementation but not yet declared on the rocketride/app-sdk Documents typing — external TypeScript builds referencing them will fail to type-check.

React subscription

import { getDocs } from './docs';

const MyComponent: React.FC = () => {
const state = getDocs().useStore(); // re-renders on any state change

const activeGroup = state.groups[state.activeGroupId];
const activeEditorId = activeGroup?.editorIds[activeGroup.activeEditorIndex];
const activeEditor = activeEditorId ? state.editors[activeEditorId] : undefined;
const activeDoc = activeEditor ? state.documents[activeEditor.documentUri] : undefined;

return <div>{activeDoc?.uri ?? 'No document open'}</div>;
};

Sharing across your app's tree

Components in different parts of your app's layout (e.g. your editor area and your sidebar) can share the same Documents instance via the module-level getDocs() function:

MyApp (creates instance)     MySidebar (uses same instance)
↓ ↓
createDocs(vfs) getDocs()
↓ ↓
getDocs().useStore() getDocs().openDocument(path)

Content type

Document.content is unknown, the exact object you store is the exact object you get back. No serialization happens inside the Documents class. The VFS handles serialization at the disk boundary.

  • Pipeline editor: stores a PipelineConfig object
  • Text editor: stores a string
  • Image editor: stores a base64 string or metadata object

The Virtual File System (IVirtualFileSystem)

The single abstraction for all file I/O. Created by the app, passed to both new Documents(vfs) and DocExplorer.

interface IVirtualFileSystem {
list(dir: string): Promise<{ name: string; type: 'file' | 'dir' }[]>;
read(path: string): Promise<unknown>;
write(path: string, content: unknown): Promise<void>;
rename(oldPath: string, newPath: string): Promise<void>;
delete(path: string): Promise<void>;
mkdir(path: string): Promise<void>;
}

Example: RocketRide client VFS

const vfs: IVirtualFileSystem = {
list: async (dir) => {
const result = await client.fsListDir(`projects/${dir}`);
return result.entries.map(e => ({ name: e.name, type: e.type }));
},
read: (path) => client.fsReadJson(`projects/${path}`),
write: (path, content) => client.fsWriteJson(`projects/${path}`, content),
rename: (old, new_) => client.fsRename(`projects/${old}`, `projects/${new_}`),
delete: (path) => client.fsDelete(`projects/${path}`),
mkdir: (path) => client.fsMkdir(`projects/${path}`),
};

Example: REST API VFS

const vfs: IVirtualFileSystem = {
list: async (dir) => {
const res = await fetch(`/api/files?dir=${dir}`);
return res.json();
},
read: async (path) => {
const res = await fetch(`/api/files/${path}`);
return res.json();
},
write: async (path, content) => {
await fetch(`/api/files/${path}`, { method: 'PUT', body: JSON.stringify(content) });
},
rename: async (old, new_) => {
await fetch(`/api/files/${old}/rename`, { method: 'POST', body: JSON.stringify({ to: new_ }) });
},
delete: async (path) => {
await fetch(`/api/files/${path}`, { method: 'DELETE' });
},
mkdir: async (path) => {
await fetch(`/api/files/${path}`, { method: 'POST', body: JSON.stringify({ type: 'dir' }) });
},
};

DocExplorer: File Tree Component

A generic file tree panel (like VS Code's EXPLORER). Renders a hierarchical file tree with:

  • Directory expand/collapse
  • Tree/flat view toggle
  • File selection and active highlight
  • Inline rename and create
  • Context menus (rename/delete)
  • Status dots (running/error/warning)
  • Optional child items under files (with action buttons)
  • Keyboard navigation
import { DocExplorer } from 'shell';
import type { DocExplorerConfig, DocEntry } from 'shell';
import type { IVirtualFileSystem } from 'rocketride/app-sdk';

const config: DocExplorerConfig = {
title: 'My Files',
extensions: ['.txt', '.md'],
displayName: (name) => name.replace(/\.(txt|md)$/, ''),
emptyMessage: 'No files yet',
createPlaceholder: 'file name',
};

<DocExplorer
vfs={myVfs}
config={config}
entries={entries}
statuses={statusMap}
isConnected={isConnected}
activeFilePath={activeUri}
onOpenFile={(path) => getDocs().openDocument(path)}
onFileManage={(action, path, newName) => { /* rename/delete/create */ }}
onRefresh={() => refreshFileList()}
/>

DocExplorerConfig

FieldTypeDefaultDescription
titlestring(required)Section header (e.g. "Pipelines", "Photos")
extensionsstring[] | nullnullFile extensions to filter. Null = show all.
displayName(filename: string) => stringStrips known extensionsCustom display name formatter
createPlaceholderstring'file name'Placeholder for inline create input
emptyMessagestring'No files'Empty state message

DocTabs: Tab Bar Component

A tab bar UI for a single editor group. Takes a Documents instance as a prop.

import { DocTabs } from 'shell';
import { getDocs } from './docs';

<DocTabs
docs={getDocs()}
groupId={groupId}
onDirtyClose={(editorId, uri) => {
// Show "save changes?" dialog
}}
/>

Features:

  • Tab per editor in the group
  • Dirty indicator (dot)
  • Close button (on hover)
  • Active tab highlight
  • Calls docs.setActiveEditor() / docs.closeEditor() on the provided instance

Cross-App Component Loading

Apps can expose components for other apps to use, and load components from other apps at runtime via Module Federation.

Exposing components

Add them to your components object in the AppDescriptor. They're bundled automatically because they're imported, no extra exposes in rsbuild needed.

const MY_APP: AppDescriptor = {
id: 'rocketride.myApp',
name: 'My App',
branding: { appName: 'My App' },
app: MyApp,
components: {
// Shell never mounts these, but other apps can access them
SpecialChart: MySpecialChartComponent,
DataGrid: MyDataGridComponent,
},
};

Your rsbuild.config.ts still only needs one expose:

exposes: {
'./AppDescriptor': './src/AppDescriptor.ts',
}

When the shell loads your AppDescriptor, all components referenced in components are included in the bundle automatically.

Loading components from another app

Use useAppComponent(), it lazy-loads the target app's descriptor if needed and returns the component once available:

import { useAppComponent } from 'rocketride/app-sdk';

const MyComponent: React.FC = () => {
const Chart = useAppComponent('rocketride.otherApp', 'SpecialChart');

if (!Chart) return <div>Loading...</div>;
return <Chart data={myData} />;
};

The hook:

  • Returns null while the target app's descriptor is loading
  • Triggers a lazy load automatically if the app hasn't been visited yet
  • Returns the component once available, no manual loading needed

Theming

The shell manages themes via CSS custom properties. Your app should use --rr-* variables for all colors, fonts, and borders.

Available CSS variables

VariablePurpose
--rr-bg-defaultMain background
--rr-bg-paperCard/panel background
--rr-bg-surface-altHover/alternate background
--rr-bg-inputInput field background
--rr-text-primaryPrimary text
--rr-text-secondarySecondary/muted text
--rr-text-disabledDisabled text
--rr-brandBrand/accent color
--rr-borderBorder color
--rr-font-familyPrimary font
--rr-font-family-monoMonospace font
--rr-color-successSuccess green
--rr-color-warningWarning orange
--rr-color-errorError red

Responding to theme changes

useEffect(() => {
return connectionManager.on('shell:themeChange', ({ tokens }) => {
// tokens is a Record<string, string> of all --rr-* values
console.log('New theme:', tokens['--rr-brand']);
});
}, []);

Build Configuration

See the Getting Started section for the complete rsbuild.config.ts template.

Key points:

  • The MF container name is derived automatically from appManifest.id in package.json
  • Always expose ./AppDescriptor as the single MF entry point
  • rocketride/app-sdk must be a shared singleton so your app and the shell agree on one instance
  • React and react-dom must be shared singletons to avoid duplicate instances

For the App Builder loop, see App Builder.