Shell API
Shell API
A complete guide for developers building micro-frontend applications for the RocketRide Cloud platform.
Table of Contents
- Architecture Overview
- Getting Started
- The App Manifest
- The App Descriptor
- Shell Props: What Your App Receives
- Screen Zones
- Shell Hooks & APIs
- The Connection Manager (connectionManager)
- The Documents System
- The Virtual File System (IVirtualFileSystem)
- DocExplorer: File Tree Component
- DocTabs: Tab Bar Component
- Cross-App Component Loading
- Theming
- 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 asconfig['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>;
};
}
appis the single component the shell mounts in the client area; the app composes its own layout inside (columns, sidebar, status bar) with<AppLayout>componentsentries (e.g.Canvas,Toolbar,DataGrid) are never mounted by the shell but are loadable by other apps viauseAppComponent()
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:
iconDark/iconLight(matched to the active palette mode)icon(generic branding icon)- Manifest
iconURL (frompackage.json) - 2-letter monogram fallback
Pre-built theme-aware SVGs are available in shared/assets/rocketride/:
rocketride-dark.svg: light body (#E0DDF0) for dark backgroundsrocketride-light.svg: dark body (#1E1A34) for light backgroundsrocketride.svg:currentColorbody, 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
| Zone | Owner | Content |
|---|---|---|
| Sidebar | Your app component's layout (AppLayout) | App switcher header, your sidebar content, theme/account/settings footer |
| Client Area | Your app component | Whatever your app renders |
| Debug Panel | Shell (ALT+D toggle) | Live event log, postMessage traffic |
| Status Bar | Shell | Connection status, app name, ready state |
| Overlays | Shell | Account, 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/Function | Returns | Purpose |
|---|---|---|
useShellConnection() | { client, isConnected, statusMessage } | Access the RocketRide WebSocket client |
useShellApiConfig() | ShellApiConfig | Read runtime config (API URLs, keys, user settings) |
getClient() | RocketRideClient | null | Non-React access to the client singleton |
Auth
| Hook | Returns | Purpose |
|---|---|---|
useAuthUser() | ConnectResult | null | Current authenticated user identity |
useLogout() | () => void | Trigger logout |
useSubscriptions() | { desktopApps, isOnDesktop(appId), getStatus(appId) } | User's desktop apps and subscription state |
Workspace
| Hook | Returns | Purpose |
|---|---|---|
useWorkspace() | IWorkspaceContext | Access 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 settingsactiveAppId: Current app IDappManifest: All registered appsappLoading: Whether the active app is currently loadingloadedApps: Map of already-loaded app descriptors (for cross-app component loading)loadApp(appId): Trigger lazy loading of an app's descriptorupdateAppState(updater): Update per-app state — pass a function receiving the previous state and returning the nextupdateSetting(key, value): Update a single settingupdatePrefs(patch): Update workspace preferencesthemeOptions: Available theme choicessetTheme(themeId): Switch the active themedispatch(action): Update prefs or switch appsemit(event, payload)/on(event, handler): Event bus (delegates to connectionManager)
Workspace Lifecycle: seeded vs loaded
The workspace has a two-phase startup:
-
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. -
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
seededis true. It is then up to each app to decide whether it needs to wait forloaded. - Unauthenticated apps (
authenticated: false) can render immediately on seeded state, they receiveisConnected=falseandidentity=nulland should be designed to work with those values. - Authenticated apps that depend on persisted settings (API keys, saved state) should gate on
loadedbefore 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
loadedis 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
| Event | Payload | Direction | Description |
|---|---|---|---|
shell:connected | {} | Shell → Apps | WebSocket connection established |
shell:disconnected | { reason: string; hasError: boolean } | Shell → Apps | WebSocket connection lost |
shell:login | { user: ConnectResult } | Shell → Apps | User authenticated |
shell:logout | {} | Shell → Apps | User logged out |
shell:loginRequest | { appId?: string } | Apps → Shell | Request OAuth login (optionally targeting an app) |
shell:logoutRequest | {} | Apps → Shell | Request logout |
shell:switchApp | { appId: string } | Apps → Shell | Switch the active app |
shell:subscribe | { app: AppManifestEntry, plan?: CheckoutPlan } | Apps → Shell | Open subscription checkout for an app; optional plan preselects a tier and skips the picker (straight to payment) |
shell:myApps | {} | Apps → Shell | Navigate to My Apps |
shell:accountUpdate | ConnectResult | Server → Shell | Server-pushed account/subscription change |
shell:sidebarCollapsing | {} | Shell → Apps | Sidebar is collapsing (for layout adjustments) |
shell:themeChange | { tokens: Record<string, string> } | Shell → Apps | Theme CSS tokens changed |
shell:statusChange | { message: string | null } | Shell → Apps | Transient status bar text changed |
shell:event | { event: unknown } | Server → Apps | Raw server event forwarded from WebSocket |
shell:appsUpdated | { apps: ShellAppEntry[] } | Shell-internal | App list updated |
shell:manifestRefresh | { source: string } | Shell-internal | App manifest re-fetched |
shell:openOverlay | { id: 'account' | 'settings' | 'environment' } | Shell-internal | Open a shell overlay |
shell:unsubscribe | { appId: string } | Shell-internal | Cancel an app subscription |
shell:viewActivated | { viewId: string } | Shell-internal | A 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
| Concept | Description |
|---|---|
| Document | One per URI. Holds content in memory. Tracks dirty state and version. |
| Editor | A view onto a Document. Independent viewport state (scroll, cursor). Multiple editors can view the same document. |
| EditorGroup | A 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:
| Method | Description |
|---|---|
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
PipelineConfigobject - 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
| Field | Type | Default | Description |
|---|---|---|---|
title | string | (required) | Section header (e.g. "Pipelines", "Photos") |
extensions | string[] | null | null | File extensions to filter. Null = show all. |
displayName | (filename: string) => string | Strips known extensions | Custom display name formatter |
createPlaceholder | string | 'file name' | Placeholder for inline create input |
emptyMessage | string | '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
nullwhile 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
| Variable | Purpose |
|---|---|
--rr-bg-default | Main background |
--rr-bg-paper | Card/panel background |
--rr-bg-surface-alt | Hover/alternate background |
--rr-bg-input | Input field background |
--rr-text-primary | Primary text |
--rr-text-secondary | Secondary/muted text |
--rr-text-disabled | Disabled text |
--rr-brand | Brand/accent color |
--rr-border | Border color |
--rr-font-family | Primary font |
--rr-font-family-mono | Monospace font |
--rr-color-success | Success green |
--rr-color-warning | Warning orange |
--rr-color-error | Error 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
nameis derived automatically fromappManifest.idinpackage.json - Always expose
./AppDescriptoras the single MF entry point rocketride/app-sdkmust 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.