Files

196 lines
8.0 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)
→ if the bundle code-splits, lazy chunks load from the same runtime
endpoint (output.publicPath = /api/plugin/:id/runtime/), served as
plain .js assets
→ 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
→ if manifest.devMode is true (dev loop only), the loader polls the
manifest every 4s and remounts the plugin when buildId changes —
no page refresh (production manifests never set devMode)
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 entry bundle (webpack/vite output)
├── <name>-<hash>.chunk.js # optional lazy chunks (code splitting) — FLAT names
├── *.js.map # optional source maps (served as application/json)
└── styles.css # optional stylesheet (Tailwind/SCSS output OK)
plugin.json # manifest
```
## Shared dependency bridging
Plugin webpack config marks ONLY the React singleton as external, mapping to
a host global:
```javascript
externals: {
'react': 'window.__TEABLE_HOST__.React',
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
}
```
Everything else on the host (`SDK`, `UILib`, `OpenAPI`, `PluginSDK`) is read
from `window.__TEABLE_HOST__` at runtime — there is NO working externals
mapping for `@teable/*` packages (a value import via the old
window-global-externals style compiled to `undefined`; via monorepo source
aliases it would inline the whole package source — see troubleshooting.md
Pitfall 1). `react/jsx-runtime` is intentionally NOT external: the jsx
automatic transform bundles it, which is safe — it only creates element
objects, while hooks/context/state still resolve through the externalized
`react` → host singleton.
Other npm dependencies (react-router, charts, lodash…) are bundled into the
plugin as normal.
## Two ways to access host APIs
### 1. Through `window.__TEABLE_HOST__` (the only runtime path)
```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/plugin-showcase/` and `plugins/batch-edit-pro/` use —
the plugin bundle has no `@teable/*` runtime imports, everything resolves
through the host global. `import { useState } from 'react'` also works (the
externals mapping above redirects it to the host singleton).
### 2. `import type` from `@teable/*` (type-checking only)
```typescript
import type { ITeableHost } from '@teable/plugin-sdk';
export const host = window.__TEABLE_HOST__ as unknown as ITeableHost;
```
`import type` is erased at compile time — it never reaches the bundle. This
is how scaffolded projects get full type safety inside the monorepo
(standalone scaffolds ship a loose-typed local host module instead).
## 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 app at `/base/:baseId/p/:pluginId` (whole viewport inside BaseLayout; own router OK, HashRouter recommended) | `ctx.addRoute(id, component)` |