Skip to main content

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

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);
    }
  }
}

Platform-Specific Plugins

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
  }
}

Performance Optimization

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.
The effect method only runs once per provider class. If you need per-instance initialization, use the setup method instead.
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 */]);
  }
}