Plugin Architecture
AFFiNE’s plugin system provides a powerful way to extend the application with custom functionality. This guide covers the plugin architecture, development workflow, and best practices.
The plugin system is currently under active development. Some features may be in preview or subject to change. This documentation reflects the current architecture and planned capabilities.
Overview
AFFiNE’s extensibility is built on multiple layers:
BlockSuite Extensions Editor-level extensions for blocks, widgets, and commands
View Extensions UI components and visual customizations
Platform Plugins Native platform integrations (Electron, Capacitor)
Extension Loader System
The extension loader provides structured management of extensions:
Base Extension Provider
Create custom providers with type-safe options:
import { BaseExtensionProvider } from '@blocksuite/affine-ext-loader' ;
import { z } from 'zod' ;
import type { Context } from '@blocksuite/affine-ext-loader' ;
class MyProvider extends BaseExtensionProvider < 'my-scope' , { enabled : boolean }> {
name = 'MyProvider' ;
schema = z . object ({
enabled: z . boolean (),
});
setup ( context : Context < 'my-scope' >, options ?: { enabled : boolean }) {
super . setup ( context , options );
// Custom setup logic
if ( options ?. enabled ) {
this . registerExtensions ( context );
}
}
private registerExtensions ( context : Context < 'my-scope' >) {
// Register your extensions
}
}
See: ~/workspace/source/blocksuite/affine/ext-loader/README.md:9
Extension Context
The context provides access to the extension registry:
interface ExtensionContext {
register ( extensions : ExtensionType []) : void ;
scope : ViewScope | 'store' ;
}
Store Extension Plugins
Creating Store Providers
Store providers manage data-layer extensions:
import {
StoreExtensionProvider ,
StoreExtensionManager ,
type StoreExtensionContext
} from '@blocksuite/affine-ext-loader' ;
import { z } from 'zod' ;
class MyStoreProvider extends StoreExtensionProvider <{ cacheSize : number }> {
override name = 'MyStoreProvider' ;
override schema = z . object ({
cacheSize: z . number (). min ( 0 ),
});
override setup ( context : StoreExtensionContext , options ?: { cacheSize : number }) {
super . setup ( context , options );
context . register ([ Ext1 , Ext2 , Ext3 ]);
}
}
// Usage
const manager = new StoreExtensionManager ([ MyStoreProvider ]);
manager . configure ( MyStoreProvider , { cacheSize: 100 });
const extensions = manager . get ( 'store' );
See: ~/workspace/source/blocksuite/affine/ext-loader/README.md:34
Store Extension Lifecycle
export class MyStoreExtension extends StoreExtension {
static readonly key = 'my-extension' ;
constructor ( readonly store : Store ) {
super ( store );
}
// Called when document loads
loaded () {
console . log ( 'Store extension loaded' );
this . initializeData ();
}
// Called when document is disposed
disposed () {
console . log ( 'Store extension disposed' );
this . cleanup ();
}
private initializeData () {
// Initialize extension data
}
private cleanup () {
// Clean up resources
}
}
View Extension Plugins
Creating View Providers
View providers manage UI and rendering extensions:
import {
ViewExtensionProvider ,
ViewExtensionManager ,
type ViewExtensionContext
} from '@blocksuite/affine-ext-loader' ;
import { z } from 'zod' ;
class MyViewProvider extends ViewExtensionProvider <{ theme : string }> {
override name = 'MyViewProvider' ;
override schema = z . object ({
theme: z . enum ([ 'light' , 'dark' ]),
});
override setup ( context : ViewExtensionContext , options ?: { theme : string }) {
super . setup ( context , options );
context . register ([ CommonExt ]);
if ( context . scope === 'page' ) {
context . register ([ PageExt ]);
} else if ( context . scope === 'edgeless' ) {
context . register ([ EdgelessExt ]);
}
if ( options ?. theme === 'dark' ) {
context . register ([ DarkModeExt ]);
}
}
// One-time initialization
override effect () {
console . log ( 'Initializing MyViewProvider' );
this . registerLitElements ();
}
}
// Usage
const manager = new ViewExtensionManager ([ MyViewProvider ]);
manager . configure ( MyViewProvider , { theme: 'dark' });
const pageExtensions = manager . get ( 'page' );
const edgelessExtensions = manager . get ( 'edgeless' );
See: ~/workspace/source/blocksuite/affine/ext-loader/README.md:60
View Scopes
View extensions support multiple rendering contexts:
page - Standard page view
edgeless - Edgeless/whiteboard view
preview-page - Page preview
preview-edgeless - Edgeless preview
mobile-page - Mobile page view
mobile-edgeless - Mobile edgeless view
One-time Initialization
The effect method runs once per provider class:
class MyViewProvider extends ViewExtensionProvider {
override effect () {
// This runs only once, even if multiple instances are created
initializeGlobalState ();
registerLitElements ();
setupGlobalEventListeners ();
}
}
Use the effect method for:
Initializing global state
Registering custom elements
Setting up shared resources
See: ~/workspace/source/blocksuite/affine/ext-loader/README.md:109
Extension Configuration
Dynamic Configuration
Configure extensions at runtime:
// Set configuration directly
manager . configure ( MyProvider , { enabled: true });
// Update using a function
manager . configure ( MyProvider , prev => {
if ( ! prev ) return prev ;
return {
... prev ,
enabled: ! prev . enabled ,
};
});
// Remove configuration
manager . configure ( MyProvider , undefined );
See: ~/workspace/source/blocksuite/affine/ext-loader/README.md:134
Schema Validation
Use Zod schemas for type-safe configuration:
import { z } from 'zod' ;
const configSchema = z . object ({
apiKey: z . string (). min ( 1 ),
endpoint: z . string (). url (),
timeout: z . number (). min ( 0 ). max ( 30000 ),
retries: z . number (). int (). min ( 0 ). max ( 5 ),
features: z . object ({
analytics: z . boolean (),
notifications: z . boolean (),
}),
});
type Config = z . infer < typeof configSchema >;
class MyPlugin extends BaseExtensionProvider < 'plugin' , Config > {
override schema = configSchema ;
override setup ( context : Context < 'plugin' >, options ?: Config ) {
// options is type-safe and validated
if ( options ?. features . analytics ) {
this . enableAnalytics ( options . apiKey );
}
}
}
Capacitor Plugins (Mobile)
For mobile platforms, use Capacitor plugins:
import { registerPlugin } from '@capacitor/core' ;
export interface MyPlugin {
initialize ( options : { apiKey : string }) : Promise < void >;
performAction ( data : ActionData ) : Promise < ActionResult >;
}
const MyPlugin = registerPlugin < MyPlugin >( 'MyPlugin' );
export { MyPlugin };
export interface AffineThemePlugin {
getTheme () : Promise <{ theme : string }>;
setTheme ( options : { theme : string }) : Promise < void >;
}
See: ~/workspace/source/packages/frontend/apps/android/src/plugins/affine-theme/index.ts:1
Available Mobile Plugins
AFFiNE includes several mobile plugins:
Theme Plugin Manages native theme integration
Auth Plugin Handles authentication flows
Storage Plugin Native storage and NBStore integration
AI Button Plugin AI features integration
Dependency Injection
Using DI Container
Access managers through dependency injection:
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader' ;
import { StdIdentifier } from '@blocksuite/affine/std' ;
class MyComponent {
private viewManager : ViewExtensionManager ;
constructor ( std : Std ) {
this . viewManager = std . get ( ViewExtensionManagerIdentifier );
}
loadExtensions () {
const pageExtensions = this . viewManager . get ( 'page' );
// Use extensions...
}
}
See: ~/workspace/source/blocksuite/affine/ext-loader/README.md:154
Plugin Development Workflow
Step 1: Define Plugin Interface
export interface MyPluginOptions {
enabled : boolean ;
config : {
apiKey : string ;
baseUrl : string ;
};
}
export interface MyPluginAPI {
initialize () : Promise < void >;
performAction ( data : unknown ) : Promise < unknown >;
dispose () : Promise < void >;
}
Step 2: Create Store Extension
import { StoreExtensionProvider } from '@blocksuite/affine-ext-loader' ;
export class MyPluginStoreExtension extends StoreExtensionProvider < MyPluginOptions > {
override name = 'my-plugin' ;
override schema = optionsSchema ;
override setup ( context : StoreExtensionContext , options ?: MyPluginOptions ) {
super . setup ( context , options );
if ( ! options ?. enabled ) return ;
context . register ([
MyDataSchemaExtension ,
MyAdapterExtensions ,
]);
}
}
Step 3: Create View Extension
import { ViewExtensionProvider } from '@blocksuite/affine-ext-loader' ;
export class MyPluginViewExtension extends ViewExtensionProvider < MyPluginOptions > {
override name = 'my-plugin' ;
override schema = optionsSchema ;
override effect () {
super . effect ();
this . registerComponents ();
}
override setup ( context : ViewExtensionContext , options ?: MyPluginOptions ) {
super . setup ( context , options );
if ( ! options ?. enabled ) return ;
context . register ([
MyBlockViewExtension ,
MyWidgetExtension ,
]);
}
private registerComponents () {
customElements . define ( 'my-plugin-component' , MyPluginComponent );
}
}
Step 4: Register Plugin
import { StoreExtensionManager , ViewExtensionManager } from '@blocksuite/affine-ext-loader' ;
const storeManager = new StoreExtensionManager ([
MyPluginStoreExtension ,
]);
const viewManager = new ViewExtensionManager ([
MyPluginViewExtension ,
]);
storeManager . configure ( MyPluginStoreExtension , {
enabled: true ,
config: {
apiKey: process . env . API_KEY ,
baseUrl: 'https://api.example.com' ,
},
});
viewManager . configure ( MyPluginViewExtension , {
enabled: true ,
config: {
apiKey: process . env . API_KEY ,
baseUrl: 'https://api.example.com' ,
},
});
Best Practices
Type Safety Always use Zod schemas for runtime validation and TypeScript types.
Lazy Loading Load plugin code only when needed to reduce initial bundle size.
Error Handling Gracefully handle initialization failures without breaking the app.
Cleanup Always clean up resources in disposal hooks.
Error Handling Pattern
export class SafePluginProvider extends ViewExtensionProvider {
override setup ( context : ViewExtensionContext , options ?: PluginOptions ) {
try {
super . setup ( context , options );
this . initializePlugin ( options );
} catch ( error ) {
console . error ( 'Plugin initialization failed:' , error );
// Optionally notify user
this . handleInitializationError ( error );
}
}
private handleInitializationError ( error : unknown ) {
// Send to error tracking
// Show user notification
// Fallback to safe defaults
}
}
export class OptimizedPlugin extends ViewExtensionProvider {
private initialized = false ;
override effect () {
if ( this . initialized ) return ;
this . initialized = true ;
// Heavy initialization only once
super . effect ();
this . preloadResources ();
}
private async preloadResources () {
// Lazy load dependencies
const module = await import ( './heavy-module' );
this . configureModule ( module );
}
}
Testing Plugins
Unit Testing
import { describe , it , expect , beforeEach } from 'vitest' ;
import { StoreExtensionManager } from '@blocksuite/affine-ext-loader' ;
import { MyPluginStoreExtension } from './my-plugin' ;
describe ( 'MyPlugin' , () => {
let manager : StoreExtensionManager ;
beforeEach (() => {
manager = new StoreExtensionManager ([ MyPluginStoreExtension ]);
});
it ( 'should register extensions when enabled' , () => {
manager . configure ( MyPluginStoreExtension , {
enabled: true ,
config: { apiKey: 'test' },
});
const extensions = manager . get ( 'store' );
expect ( extensions ). toBeDefined ();
expect ( extensions . length ). toBeGreaterThan ( 0 );
});
it ( 'should not register extensions when disabled' , () => {
manager . configure ( MyPluginStoreExtension , { enabled: false });
const extensions = manager . get ( 'store' );
expect ( extensions ). toBeDefined ();
});
});
Integration Testing
import { test , expect } from '@playwright/test' ;
test ( 'plugin loads and functions correctly' , async ({ page }) => {
await page . goto ( '/workspace' );
// Wait for plugin to initialize
await page . waitForSelector ( '[data-plugin="my-plugin"]' );
// Test plugin functionality
await page . click ( '[data-plugin-action="test"]' );
const result = await page . locator ( '[data-plugin-result]' ). textContent ();
expect ( result ). toBe ( 'Expected Result' );
});
Plugin Marketplace (Coming Soon)
AFFiNE is planning a plugin marketplace for discovering and installing community plugins. The following describes planned capabilities.
Plugin Manifest
{
"name" : "my-affine-plugin" ,
"version" : "1.0.0" ,
"description" : "A custom plugin for AFFiNE" ,
"author" : "Your Name" ,
"license" : "MIT" ,
"affine" : {
"minVersion" : "0.10.0" ,
"maxVersion" : "1.0.0"
},
"permissions" : [
"storage" ,
"network" ,
"clipboard"
],
"entry" : "dist/index.js" ,
"extensions" : {
"store" : "MyPluginStoreExtension" ,
"view" : "MyPluginViewExtension"
}
}
Troubleshooting
Check that the extension is properly registered with the manager and that the configuration is valid. Verify the extension’s name property matches what’s being referenced.
Ensure your Zod schema matches the TypeScript interface. Use z.infer<typeof schema> to generate types from schemas.
Effect method not running
The effect method only runs once per provider class. If you need per-instance initialization, use the setup method instead.
Scope-specific extensions not working
Verify that you’re checking context.scope correctly and that the view manager is being called with the right scope parameter.
Migration Guide
From Legacy Extensions
If you have extensions built for older AFFiNE versions:
// Old approach
class OldExtension extends Extension {
setup ( di : Container ) {
// Manual DI registration
}
}
// New approach
class NewExtension extends StoreExtensionProvider {
override name = 'my-extension' ;
override setup ( context : StoreExtensionContext ) {
super . setup ( context );
context . register ([ /* extensions */ ]);
}
}