docs(skill): plugin bundle pipeline v2 (IIFE/多资产/热更新) 文档入仓
This commit is contained in:
@@ -1,18 +1,37 @@
|
||||
# Plugin development workflow
|
||||
|
||||
The standard recipe for creating, building, and deploying a dynamic React
|
||||
component plugin. Each plugin is a standalone webpack project that outputs
|
||||
an IIFE bundle.
|
||||
component plugin. Each plugin is a standalone webpack (or vite) project that
|
||||
outputs an IIFE bundle. Since 2026-10 the pipeline is multi-asset: lazy
|
||||
chunks, source maps, and multiple CSS files upload alongside the entry.
|
||||
|
||||
## Output contract
|
||||
|
||||
Every plugin task ends with:
|
||||
1. A built IIFE bundle (`dist/main.js` + optional `dist/styles.css`)
|
||||
1. A built IIFE bundle (`dist/main.js` + optional chunks/`*.chunk.js` +
|
||||
`*.js.map` source maps + optional `styles.css`)
|
||||
2. A `plugin.json` manifest
|
||||
3. Uploaded to the host via `POST /api/plugin/:id/upload-bundle`
|
||||
(or `teable-plugin deploy` which handles all assets)
|
||||
4. Enabled via `PUT /api/base/:baseId/plugin-config/:pluginId`
|
||||
5. Installed to the target position (the install API varies by position)
|
||||
|
||||
## Dev loop (hot reload — preferred while iterating)
|
||||
|
||||
```bash
|
||||
cd plugins/my-plugin # or wherever the project lives
|
||||
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> pnpm dev
|
||||
# or: node ../../packages/plugin-cli/dist/index.js dev -d .
|
||||
```
|
||||
|
||||
This runs `webpack --watch` (production mode — dev-mode output carries
|
||||
`/******/` banners that fail the IIFE validator), auto-uploads on every
|
||||
change with a `devMode: true, buildId: <ts>` manifest, and the open host
|
||||
page remounts the plugin within ~4s WITHOUT a page refresh. Production
|
||||
deploys (no devMode in manifest) never poll. Verified 2026-10-13 on
|
||||
plugin-showcase: edit → ~5s live update, window markers survive (SPA-level
|
||||
remount, not a reload).
|
||||
|
||||
## Step 1 — Understand the request
|
||||
|
||||
Clarify what the user wants, then pick the position using `overview.md`.
|
||||
@@ -26,23 +45,33 @@ to npm). Run it directly:
|
||||
```bash
|
||||
cd /tmp
|
||||
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin
|
||||
# or the vite template:
|
||||
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin --template vite
|
||||
cd my-plugin
|
||||
pnpm install
|
||||
```
|
||||
|
||||
The scaffold adapts automatically: inside the monorepo it wires
|
||||
`@teable/plugin-sdk: workspace:*` + source aliases; standalone projects get a
|
||||
loose-typed local host module (the npm package is not published). Vite
|
||||
scaffolds also generate a `pnpm-workspace.yaml` with `allowBuilds: esbuild`
|
||||
(pnpm 11 fails `pnpm install` without it).
|
||||
|
||||
This generates:
|
||||
```
|
||||
my-plugin/
|
||||
├── src/
|
||||
│ └── index.tsx # entry: calls definePlugin()
|
||||
│ ├── host.ts # typed host accessor
|
||||
│ └── index.tsx # entry: definePlugin via host.PluginSDK
|
||||
├── plugin.json # manifest
|
||||
├── webpack.config.js # pre-configured externals
|
||||
├── webpack.config.js # or vite.config.ts
|
||||
├── tsconfig.json
|
||||
└── package.json
|
||||
└── package.json # dev/build/deploy scripts
|
||||
```
|
||||
|
||||
If the CLI doesn't work for some reason, manually create the structure by
|
||||
copying `plugins/batch-edit-pro/` as a template.
|
||||
Reference implementations in the repo: `plugins/plugin-showcase/` (chunks +
|
||||
sourcemap + Tailwind + HashRouter route app — the v2 showcase) and
|
||||
`plugins/batch-edit-pro/` (classic single-file contextMenu plugin).
|
||||
|
||||
## Step 3 — Write the plugin code
|
||||
|
||||
@@ -117,31 +146,41 @@ definePlugin({
|
||||
|
||||
## Step 4 — Build
|
||||
|
||||
```bash
|
||||
NODE_OPTIONS='--max-old-space-size=2048' npx webpack --config webpack.config.js
|
||||
```
|
||||
|
||||
Or if the plugin has a build script:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
# or: npm run build
|
||||
# = tsc --noEmit && webpack --mode production (scaffold script)
|
||||
```
|
||||
|
||||
**Rebuilding without the scaffold — use this exact config** (agents hand-writing
|
||||
webpack configs keep hitting `Error: Unsupported library type iife` — there is
|
||||
NO `output.library` in a working plugin config; a default single-entry
|
||||
production build is already IIFE, which is what the upload validator requires):
|
||||
**Rebuilding without the scaffold — use this exact config** (agents
|
||||
hand-writing webpack configs keep hitting `Error: Unsupported library type
|
||||
iife` — there is NO `output.library` in a working plugin config; a default
|
||||
single-entry production build is already IIFE, which is what the upload
|
||||
validator requires):
|
||||
|
||||
```js
|
||||
// webpack.config.js — known-good (from plugins/batch-edit-pro/)
|
||||
// webpack.config.js — known-good v2 (from plugins/plugin-showcase/)
|
||||
const path = require('path');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
|
||||
const pluginId = require('./plugin.json').id;
|
||||
|
||||
module.exports = {
|
||||
mode: 'production', // default single-entry build emits an IIFE (!function / (()=>))
|
||||
mode: 'production',
|
||||
entry: './src/index.tsx',
|
||||
output: { path: path.resolve(__dirname, 'dist'), filename: 'main.js', clean: true },
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'main.js',
|
||||
// FLAT chunk names: multipart transport strips directory parts, so
|
||||
// nested chunk paths would 404 at runtime.
|
||||
chunkFilename: '[name]-[contenthash].chunk.js',
|
||||
// required so React.lazy/import() chunks resolve from the host runtime
|
||||
publicPath: `/api/plugin/${pluginId}/runtime/`,
|
||||
clean: true,
|
||||
// terser's minified "(()=>{" fails the validator's "(() =>" check
|
||||
environment: { arrowFunction: false },
|
||||
},
|
||||
devtool: 'source-map', // maps upload + serve as application/json
|
||||
// NO output.library — never add library.type: 'iife' (webpack rejects it)
|
||||
externals: {
|
||||
'react': 'window.__TEABLE_HOST__.React',
|
||||
@@ -151,59 +190,70 @@ module.exports = {
|
||||
module: {
|
||||
rules: [
|
||||
{ test: /\.tsx?$/, loader: 'ts-loader', options: { transpileOnly: true }, exclude: /node_modules/ },
|
||||
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] },
|
||||
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'] },
|
||||
],
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
// license-comment extraction prepends a "/*!..." banner that breaks
|
||||
// the IIFE-prefix check — keep comments inline
|
||||
new TerserPlugin({ extractComments: false }),
|
||||
],
|
||||
},
|
||||
plugins: [new MiniCssExtractPlugin({ filename: 'styles.css' })],
|
||||
};
|
||||
```
|
||||
|
||||
`react/jsx-runtime` is intentionally NOT external: the jsx automatic
|
||||
transform bundles it and that is safe (element factories only — hooks still
|
||||
come from the host singleton).
|
||||
|
||||
Vite template notes: `build.lib formats: ['iife']` + rollup externals with
|
||||
`globals: { react: 'window.__TEABLE_HOST__.React', ... }`; output starts
|
||||
`(function(...)` or `var <name>=(function...` — both pass the validator.
|
||||
IIFE cannot code-split (dynamic imports inline) — use the webpack template
|
||||
if you need lazy chunks.
|
||||
|
||||
If you build outside the monorepo, drop the `resolve.alias` block (it exists
|
||||
only so TS can type-check `import type` from `@teable/*` sources) and use
|
||||
`import type` + `as unknown as` casts instead of runtime imports.
|
||||
|
||||
Outputs: `dist/main.js` (IIFE) + `dist/styles.css` (if styles exist).
|
||||
|
||||
Verify the output:
|
||||
```bash
|
||||
ls -la dist/main.js dist/styles.css plugin.json
|
||||
```
|
||||
Outputs: `dist/main.js` (IIFE) + optional `dist/*.chunk.js` (lazy chunks) +
|
||||
`dist/*.js.map` + `dist/styles.css`.
|
||||
|
||||
## Step 5 — Upload the bundle
|
||||
|
||||
The upload URL includes the plugin's `id` from `plugin.json`. Extract it first,
|
||||
then upload.
|
||||
Preferred (handles entry + chunks + maps + css automatically):
|
||||
|
||||
```bash
|
||||
SESSION=$(grep auth_session /tmp/cookie_hr.txt 2>/dev/null | awk '{print $NF}')
|
||||
if [ -z "$SESSION" ]; then
|
||||
SESSION=$(curl -s -c - http://localhost:3003/api/auth/signin \
|
||||
-X POST -H 'Content-Type: application/json' \
|
||||
-d '{"email":"hr_admin@xingchen.tech","password":"Xingchen@2026"}' \
|
||||
| grep auth_session | awk '{print $NF}')
|
||||
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> \
|
||||
node ../../packages/plugin-cli/dist/index.js deploy -d .
|
||||
# or from the plugin dir: pnpm deploy
|
||||
```
|
||||
|
||||
> Account note (2026-08): `hr_admin@xingchen.tech` no longer exists on the
|
||||
> deployed instance. Create a throwaway user via `POST /api/auth/signup`
|
||||
> and add a `plugin_developer` row for it before uploading (see SKILL.md).
|
||||
fi
|
||||
Raw curl equivalent (single-file legacy form still works):
|
||||
|
||||
```bash
|
||||
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
|
||||
|
||||
curl -s -b "auth_session=$SESSION" \
|
||||
curl -s -H "Authorization: Bearer $TEABLE_PAT" \
|
||||
-F "js=@dist/main.js" \
|
||||
-F "css=@dist/styles.css" \
|
||||
-F "manifest=@plugin.json" \
|
||||
"http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle"
|
||||
|
||||
echo "PLUGIN_ID=$PLUGIN_ID"
|
||||
```
|
||||
|
||||
- The plugin `id` in the URL must match the `id` in `plugin.json`. On first
|
||||
upload, the plugin is created; on subsequent uploads, the existing plugin is
|
||||
updated.
|
||||
- Auth supports both session cookies (`auth_session`) and Personal Access Tokens
|
||||
(`Authorization: Bearer <PAT>`).
|
||||
- Returns 201 on success.
|
||||
Multipart notes (v2, 2026-10):
|
||||
- `js` field accepts up to 20 files (entry first or matched by manifest
|
||||
entry filename), `css` up to 5, `map` up to 20.
|
||||
- The plugin row must already exist (`POST /api/plugin {"id": "<kebab-id>"}`
|
||||
— custom ids require the plugin_developer whitelist; the endpoint is
|
||||
`@TokenAccess()` so a PAT works).
|
||||
- Limits: 5MB per JS/map file, 1MB per CSS, 15MB total. The bundleDir is
|
||||
WIPED before writing — stale chunks don't linger, but hand-patched files
|
||||
on disk are discarded on the next upload.
|
||||
- Auth supports session cookies (`auth_session`) and PATs
|
||||
(`Authorization: Bearer`). Runtime GETs (`/api/plugin/:id/runtime/*`) are
|
||||
session-only (PAT gets 403 by design).
|
||||
|
||||
## Step 6 — Enable (REQUIRED before install)
|
||||
|
||||
@@ -237,6 +287,11 @@ Open the relevant page and confirm the plugin renders and works:
|
||||
- Panel: bottom-right Plugins button in a table view
|
||||
- View: view-type dropdown
|
||||
- Dashboard: space dashboard page
|
||||
- Route (full-page app): `/base/<baseId>/p/<pluginId>` — install at position
|
||||
`route`, the page renders the plugin full-screen inside BaseLayout (bring
|
||||
your own router; HashRouter recommended so Next.js routing is untouched).
|
||||
Verified flow: enable via plugin-config → install via
|
||||
`POST /api/base/:baseId/plugin-installs {"position":"route"}` → open the URL.
|
||||
|
||||
Use `browser-act` to verify or ask the user to check.
|
||||
|
||||
@@ -244,14 +299,18 @@ Use `browser-act` to verify or ask the user to check.
|
||||
|
||||
Before claiming done:
|
||||
|
||||
- [ ] `dist/main.js` built and is a valid IIFE bundle
|
||||
- [ ] `dist/main.js` built and is a valid IIFE bundle (`!function` / `(function` /
|
||||
`(() =>` / `var x=(function` prefix — run `head -c 20 dist/main.js`)
|
||||
- [ ] Chunks (if any) are FLAT names (`[name]-[contenthash].chunk.js`)
|
||||
- [ ] `plugin.json` at project root with correct `id`, `entry`, `positions`
|
||||
- [ ] No hardcoded URLs/table IDs/tokens — all via host SDK
|
||||
- [ ] Loading, error, and empty states handled in the UI
|
||||
- [ ] `definePlugin()` called with `onLoad(ctx)` that registers at least one component
|
||||
- [ ] Upload returns 201, pluginId captured
|
||||
- [ ] Upload returns 2xx (assets count > 1 when chunks/maps exist)
|
||||
- [ ] Enable (plugin-config) called before install
|
||||
- [ ] Install returns 2xx
|
||||
- [ ] Plugin renders and works
|
||||
- [ ] Dev-loop deploys redone as a production upload (no `devMode` in the
|
||||
final manifest) before handover
|
||||
|
||||
If any box is unchecked, fix it.
|
||||
|
||||
Reference in New Issue
Block a user