Plugins
Stately's plugin system enables extending functionality across the entire stack. Plugins are "vertical" - they provide coordinated backend and frontend capabilities that work together seamlessly.
Plugin Philosophy
Traditional plugin systems often work at a single layer - backend middleware, frontend components, or API extensions. Stately plugins span the full stack:
┌─────────────────────────────────────────┐
│ Your Application │
├────────────────────┬────────────────────┤
│ Files │ Arrow │
│ ┌─────────────┐ │ ┌─────────────┐ │
│ │ @statelyjs/ │ │ │ @statelyjs/ │ │
│ │ files │ │ │ arrow │ │
│ └─────────────┘ │ └─────────────┘ │
│ ↓ │ ↓ │
│ ┌─────────────┐ │ ┌─────────────┐ │
│ │ stately- │ │ │ stately- │ │
│ │ files │ │ │ arrow │ │
│ └─────────────┘ │ └─────────────┘ │
└────────────────────┴────────────────────┘
When you add file upload capabilities, you get:
- Backend: Rust endpoints for upload, download, versioning
- Frontend: React components for file browsers, upload dialogs, path selectors
- Types: Generated TypeScript matching Rust types
- Integration: Path types that work in entity forms
Built-in Plugins
Stately includes two production-ready plugins:
Files Plugin
Backend (stately-files):
- Multipart and JSON file upload endpoints
- Automatic UUID v7 file versioning
- Directory listing with metadata
- Download endpoints with version support
- Path types for entity fields
Frontend (@statelyjs/files):
- File manager page component
- File browser and selector dialogs
- RelativePath form field
- Upload and download hooks
- Version history views
- File and versioned file downloading
Arrow Plugin
Backend (stately-arrow):
- Connector registry for data sources
- Support for
S3, GCS, Azure, ClickHouse, and more
- SQL query execution via
DataFusion
- Streaming
Arrow IPC responses
- Catalog and schema discovery
Frontend (@statelyjs/arrow):
- Data explorer page component
- Connector browser with schema navigation
- SQL query editor with syntax highlighting
- Streaming results table
- Query statistics and metrics
Using Plugins
Backend Integration
Add the plugin crate and wire it into your router:
use stately_files::{router as files_router, FileState, Dirs};
// Configure plugin state
impl FromRef<ApiState> for FileState {
fn from_ref(state: &ApiState) -> Self {
let dirs = Dirs::new(
state.config.cache_dir.clone(),
state.config.data_dir.clone(),
);
FileState::new(dirs)
}
}
// Add to router
pub fn app(state: ApiState) -> Router {
Router::new()
.nest("/api/entity", entity_router(state.clone()))
.nest("/api/files", files_router(state.clone()))
.with_state(state)
}
Frontend Integration
Install the plugin packages and compose them with your runtime:
import { statelyUi, statelyUiProvider, useStatelyUi } from '@statelyjs/stately';
import { stately } from '@statelyjs/stately/schema';
import { filesPlugin, filesUiPlugin } from '@statelyjs/files';
import { arrowPlugin, arrowUiPlugin } from '@statelyjs/arrow';
// Schema runtime with plugins
const schema = stately<AppSchemas>(openapiSpec, PARSED_SCHEMAS)
.withPlugin(filesPlugin())
.withPlugin(arrowPlugin());
// UI runtime with plugins (core plugin is included automatically)
const runtime = statelyUi<AppSchemas>({
client,
schema,
core: { api: { pathPrefix: '/entity' } },
options: { api: { pathPrefix: '/api/v1' } },
})
.withPlugin(filesUiPlugin({ api: { pathPrefix: '/files' } }))
.withPlugin(arrowUiPlugin({ api: { pathPrefix: '/arrow' } }));
Note: The core plugin is automatically included when you use statelyUi() or stately() from @statelyjs/stately. You never need to add it manually.
Using Plugin Features
Once integrated, plugins provide typed access to their capabilities:
// Access plugin API and utilities
const { plugins } = useStatelyUi();
const filesApi = plugins.files.api;
const { formatFileSize } = plugins.files.utils;
// Use plugin hooks
import { useFileExplore, useUpload } from '@statelyjs/files';
function MyComponent() {
const { files, navigate } = useFileExplore('/uploads');
const { upload, isUploading } = useUpload();
// ...
}
Plugin Anatomy
Backend Structure
A plugin crate typically includes:
stately-my-plugin/
├── Cargo.toml
├── src/
│ ├── lib.rs # Public exports
│ ├── router.rs # Router factory
│ ├── handlers.rs # HTTP handlers
│ ├── state.rs # Plugin state type
│ ├── request.rs # Request DTOs
│ ├── response.rs # Response DTOs
│ ├── error.rs # Error types
│ └── openapi.rs # OpenAPI generation
└── bin/
└── generate-openapi.rs
Key patterns:
- Router Factory: Generic over application state
pub fn router<S>(state: S) -> Router<S>
where
S: Clone + Send + Sync + 'static,
MyPluginState: FromRef<S>,
{
Router::new()
.route("/endpoint", get(handler))
.with_state(state)
}
- State Extraction: Via Axum's
FromRef
pub struct MyPluginState {
pub config: PluginConfig,
}
// User implements this for their app state
impl FromRef<AppState> for MyPluginState { ... }
- OpenAPI Generation: For type codegen
// bin/generate-openapi.rs
fn main() {
let output_dir = PathBuf::from(".");
let doc = stately::codegen::generate_openapi::<OpenApiDoc>(&output_dir);
println!("{}", serde_json::to_string_pretty(&doc).unwrap());
}
Frontend Structure
A plugin package typically includes:
@statelyjs/my-plugin/
├── package.json
├── src/
│ ├── index.ts # Public exports
│ ├── plugin.ts # Plugin factories
│ ├── schema.ts # Schema plugin (node types)
│ ├── api.ts # API operations
│ ├── context.tsx # Plugin context hook
│ ├── components/ # React components
│ ├── views/ # Composed views
│ ├── pages/ # Full page components
│ ├── hooks/ # React hooks
│ ├── fields/ # Form field components
│ │ ├── edit/
│ │ └── view/
│ └── codegen.ts # Codegen plugin (optional)
└── openapi.json # Generated from backend
Key patterns:
- Two-Tier Plugin: Schema + UI
import { createSchemaPlugin, type DefinePlugin } from '@statelyjs/stately/schema';
import { createUiPlugin, type DefineUiPlugin, type DefineOptions } from '@statelyjs/ui';
// Define the schema plugin type
export type MyPlugin = DefinePlugin<
'myPlugin',
MyNodeMap,
MyTypes,
MyData,
MyUtils
>;
// Schema plugin - uses createSchemaPlugin for ergonomic API
export const mySchemaPlugin = createSchemaPlugin<MyPlugin>({
name: 'myPlugin',
utils: myUtils,
setup: (ctx) => {
// Compute runtime data from schema
const data = computeMyData(ctx.schema);
return { data };
},
});
// Define plugin options
export type MyPluginOptions = DefineOptions<{
api?: { pathPrefix?: string };
}>;
// Define the UI plugin type
export type MyUiPlugin = DefineUiPlugin<
'myPlugin', // Plugin name (string literal)
MyPaths, // OpenAPI paths type
typeof MY_OPERATIONS, // Operation bindings
MyUtils, // Utility functions
MyPluginOptions // Configuration options
>;
// UI plugin - uses createUiPlugin for ergonomic API
export const myUiPlugin = createUiPlugin<MyUiPlugin>({
name: 'myPlugin',
operations: MY_OPERATIONS,
utils: myUtils,
setup: (ctx, options) => {
// Register components - no manual key generation needed
ctx.registerComponent('MyNodeType', 'edit', MyFieldEdit);
ctx.registerComponent('MyNodeType', 'view', MyFieldView);
// Return only what you're adding - no spreading required
return {};
},
});
Both createSchemaPlugin and createUiPlugin helpers provide:
- No manual spreading - Return only what's being added
- Automatic merging - Data, utils, and plugins are merged automatically
- Single type parameter - Derive everything from your
DefinePlugin/DefineUiPlugin type
The UI plugin additionally provides:
- Automatic API creation - Provide operations, get typed API
- Simplified component registration -
ctx.registerComponent() handles keys
- Path prefix merging - Handled automatically
- Context Hook: Typed access to plugin
export function useMyPlugin() {
const runtime = useStatelyUi();
return runtime.plugins.myPlugin;
}
- Codegen Plugin: Transform schemas during generation
export const myCodegenPlugin: CodegenPlugin = {
name: 'my-plugin',
// Define entrypoints to parse from the top
entrypoints: undefined, /** ["SomeExpectedType", ...], */
description: 'Detects OpenAPI definitions of relevant nodes',
match(schema) {
// Match on anything to identify that transformation should occur
return Boolean(schema?.oneOf);
},
// Finally, emit special node types
transformNode(node, context) {
// Transform matching nodes to custom types
if (matchesMyPattern(node)) {
return { nodeType: 'myCustomNodeType', ...node };
}
return node;
},
};
The Component Registry
Plugins register components for their node types using ctx.registerComponent():
// Registration (inside plugin setup function)
ctx.registerComponent('RelativePath', 'edit', RelativePathEdit);
ctx.registerComponent('RelativePath', 'view', RelativePathView);
// Lookup (done automatically by FieldEdit/FieldView)
const EditComponent = registry.components.get('RelativePath::edit::component');
This enables schema-driven rendering: when a form encounters a RelativePath node, it automatically renders the plugin's component.
Plugin Communication
Plugins can interact through the shared runtime:
function MyComponent() {
const { plugins } = useStatelyUi();
// Use files plugin to upload
const {
full_path,
path,
success,
uuid,
} = await plugins.files.api.upload({ body: formData });
// Use arrow plugin to query
const { response, error } = await plugins.arrow.api.execute_query({ body, parseAs: 'stream' });
}
Creating Your Own Plugin
See the Plugin Development guide for a complete walkthrough of creating custom plugins.
Key steps:
- Define your backend Rust crate with router and handlers
- Generate OpenAPI spec from your backend
- Create frontend package with schema and UI plugins
- Register custom node types and components
- Document integration patterns
Codegen Configuration
When using plugins that extend the schema (like Files), you need to configure codegen to include the plugin's transformations. Create a config file (e.g., stately.codegen.config.ts):
// stately.codegen.config.ts (or any name you prefer)
import { filesCodegenPlugin } from '@statelyjs/files/codegen';
import { someOtherCodegenPlugin } from 'some-other-stately-plugin';
export default [filesCodegenPlugin, someOtherCodegenPlugin];
Then run codegen with the config:
pnpm exec stately generate ./openapi.json -o ./src/generated -c ./stately.codegen.config.ts
Codegen plugins transform schema nodes during generation. For example, the files plugin detects RelativePath and UserDefinedPath patterns and generates appropriate node types.