skills/ 现包含 inkreach-teable + inkreach-teable-plugin, npx skills add 一并安装两个;install.sh 改为遍历安装 skills/*。
177 lines
6.8 KiB
Markdown
177 lines
6.8 KiB
Markdown
# Plugin system architecture
|
|
|
|
The host Next.js app (`:3002`) injects a global namespace `window.__TEABLE_HOST__`
|
|
during startup. Plugins are plain JS IIFE bundles that read this namespace at
|
|
runtime — no ES imports, no iframes, no penpal bridge.
|
|
|
|
## Why this system exists
|
|
|
|
The previous iframe/ZIP plugin system isolated plugins in sandboxed iframes,
|
|
limiting API access to a penpal postMessage bridge. Adding or changing a plugin
|
|
required rebuilding and re-deploying the host Next.js app. This system solves
|
|
both problems:
|
|
|
|
- **Full host API access**: Plugins run in the same React tree as the host,
|
|
sharing the same React singleton. They can use any SDK hook, UI component,
|
|
or OpenAPI helper directly.
|
|
- **No host rebuild**: Upload a new IIFE bundle via `upload-bundle` and it's
|
|
immediately available — the host loads it at runtime through PluginLoader.
|
|
- **Shared dependencies**: React, ReactDOM, and all `@teable/*` packages are
|
|
provided by the host, so plugin bundles are small (only contain the plugin's
|
|
own code).
|
|
|
|
The trade-off is that plugins are fully trusted (no sandboxing) — upload is
|
|
gated by a developer whitelist and bundles are validated for security.
|
|
|
|
## Runtime flow
|
|
|
|
```
|
|
1. Host app mounts
|
|
HostInitializer (<AppProvider>):
|
|
window.__TEABLE_HOST__ = {
|
|
React, ReactDOM,
|
|
SDK: { useFields, useRecords, useTableId, useViewId, ... },
|
|
UILib: { Button, Dialog, Select, ... },
|
|
OpenAPI: { getRecords, updateRecords, ... },
|
|
events: EventBus instance,
|
|
PluginSDK: { definePlugin, registerComponent, useSelection, getSelectionRecords },
|
|
// Native embeddable components (same React tree as the host app):
|
|
openFormModal, // modal wrapper around the native form
|
|
NativeGrid, // full native grid with providers
|
|
NativeForm, // full native form (Tier-1 injection props supported)
|
|
NativeFormScope, // Tier-2: state scope for custom form layouts
|
|
NativeFormField, // Tier-2: single native form row
|
|
useNativeForm, // Tier-2: headless hook { values, setValue, verify, submit, ... }
|
|
}
|
|
|
|
2. Plugin loads (PluginLoader component)
|
|
→ fetch /api/plugin/:id/runtime/plugin.json (manifest)
|
|
→ check minAppVersion compatibility
|
|
→ <link> inject CSS (if manifest.style exists)
|
|
→ <script src="/api/plugin/:id/runtime/main.js"> (IIFE executes)
|
|
→ script calls definePlugin({ id, name, onLoad, ... })
|
|
→ window.__TEABLE_PLUGINS__[pluginId] = config
|
|
→ PluginLoader reads registration, creates PluginContext, calls onLoad(ctx)
|
|
→ ctx.addContextMenuAction(...) etc. register React components to componentRegistry
|
|
→ PluginSlot renders registered components at the right mount point
|
|
|
|
3. Plugin unloads
|
|
→ onUnload(ctx) called
|
|
→ ctx auto-disposes all registered resources (components, event listeners)
|
|
→ script/link elements removed
|
|
→ window.__TEABLE_PLUGINS__[pluginId] deleted
|
|
```
|
|
|
|
## Key host modules
|
|
|
|
### HostInitializer
|
|
|
|
Mounts inside `<AppProvider>` (after SDK init). Runs once — writes all host APIs
|
|
to `window.__TEABLE_HOST__`. The object is immutable after initialization.
|
|
|
|
Location: `apps/nextjs-app/src/features/app/components/plugin/host/host-initializer.tsx`
|
|
|
|
### PluginLoader
|
|
|
|
A React component that loads a single plugin. Handles the full lifecycle:
|
|
manifest fetch → compatibility check → CSS injection → script load →
|
|
definePlugin → onLoad → onUnload → cleanup.
|
|
|
|
Each installed plugin gets its own `<PluginLoader>` instance. The loader shows
|
|
a "Loading plugin..." message during fetch/load, and an error message on failure.
|
|
|
|
Location: `apps/nextjs-app/src/features/app/components/plugin/host/plugin-loader.tsx`
|
|
|
|
### PluginSlot
|
|
|
|
Queries the component registry for all plugins registered at a given position
|
|
and renders their components.
|
|
|
|
```tsx
|
|
<PluginSlot position="contextMenu" tableId={tableId} />
|
|
<PluginSlot position="panel" tableId={tableId} viewId={viewId} />
|
|
<PluginSlot position="dashboard" baseId={baseId} />
|
|
```
|
|
|
|
Location: `apps/nextjs-app/src/features/app/components/plugin/host/plugin-slot.tsx`
|
|
|
|
### ComponentRegistry
|
|
|
|
An in-memory registry mapping `(position, pluginId, componentId) → React.FC`.
|
|
Plugins register components during `onLoad` and the registry auto-cleans up on
|
|
plugin unload.
|
|
|
|
Location: `apps/nextjs-app/src/features/app/components/plugin/host/component-registry.ts`
|
|
|
|
### EventBus
|
|
|
|
A simple pub/sub event bus shared between the host and all plugins. Plugins
|
|
subscribe via `ctx.on(...)` and emit via `ctx.emit(...)`.
|
|
|
|
Events include: `record.created`, `record.updated`, `record.deleted`,
|
|
`view.changed`, `selection.changed`, `plugin.activated`.
|
|
|
|
Location: `apps/nextjs-app/src/features/app/components/plugin/host/event-bus.ts`
|
|
|
|
## Plugin bundle structure
|
|
|
|
```
|
|
dist/
|
|
├── main.js # IIFE bundle (webpack output)
|
|
└── styles.css # optional stylesheet
|
|
plugin.json # manifest
|
|
```
|
|
|
|
## Shared dependency bridging
|
|
|
|
Plugin webpack config marks these as externals, mapping to host globals:
|
|
|
|
```javascript
|
|
externals: {
|
|
'react': 'window.__TEABLE_HOST__.React',
|
|
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
|
|
'@teable/sdk': 'window.__TEABLE_HOST__.SDK',
|
|
'@teable/ui-lib': 'window.__TEABLE_HOST__.UILib',
|
|
'@teable/openapi': 'window.__TEABLE_HOST__.OpenAPI',
|
|
'@teable/plugin-sdk': 'window.__TEABLE_HOST__.PluginSDK',
|
|
}
|
|
```
|
|
|
|
This means the plugin does NOT bundle React or host packages — it uses the
|
|
host's singleton instances. The bundle only contains the plugin's own code.
|
|
|
|
## Two ways to access host APIs
|
|
|
|
### 1. Through `window.__TEABLE_HOST__` (recommended for IIFE)
|
|
|
|
```javascript
|
|
const host = window.__TEABLE_HOST__;
|
|
const React = host.React;
|
|
const { useFields, useTableId } = host.SDK;
|
|
const { Button } = host.UILib;
|
|
const { getRecords } = host.OpenAPI;
|
|
const { definePlugin, useSelection } = host.PluginSDK;
|
|
```
|
|
|
|
This is what `plugins/batch-edit-pro/` uses — the plugin bundle has no
|
|
`@teable/*` imports, everything resolves through the host global.
|
|
|
|
### 2. Through `@teable/plugin-sdk` (also works because webpack externals)
|
|
|
|
```typescript
|
|
import { definePlugin } from '@teable/plugin-sdk';
|
|
```
|
|
|
|
At build time, webpack resolves this to `window.__TEABLE_HOST__.PluginSDK.definePlugin`.
|
|
Both styles work — the externals config handles the mapping.
|
|
|
|
## Positions and registration
|
|
|
|
| Position | Mount point | Registration method |
|
|
|---|---|---|
|
|
| ContextMenu | Right-click → "Plugins" group in context menu | `ctx.addContextMenuAction(id, component)` |
|
|
| Panel | Right-side drawer of a table view | `ctx.addPanel(id, component)` |
|
|
| View | View-type dropdown (alongside grid/form/gallery) | `ctx.addView(id, component)` |
|
|
| Dashboard | Grid card on space dashboard | `ctx.addDashboardWidget(id, component)` |
|
|
| Route | Full-page route (e.g., `/plugin/:pluginId/my-page`) | `ctx.addRoute(path, component)` |
|