docs(skill): plugin bundle pipeline v2 (IIFE/多资产/热更新) 文档入仓
This commit is contained in:
@@ -23,18 +23,31 @@ rebuilding the host Next.js app. Plugins are IIFE bundles — they ship as plain
|
||||
JS that executes in the host page and accesses host APIs through
|
||||
`window.__TEABLE_HOST__`.
|
||||
|
||||
> **Contract**: A plugin = a webpack IIFE bundle (`dist/main.js` + optional
|
||||
> `dist/styles.css`) + `plugin.json` manifest, uploaded to the host via
|
||||
> `POST /api/plugin/:id/upload-bundle`. It runs in the same React tree as the host
|
||||
> and has full access to the host's SDK, UI library, and OpenAPI helpers.
|
||||
> **Contract**: A plugin = a webpack (or vite) IIFE bundle (`dist/main.js` +
|
||||
> optional lazy chunks `*.chunk.js`, source maps `*.js.map`, `styles.css`) +
|
||||
> a `plugin.json` manifest, uploaded to the host via
|
||||
> `POST /api/plugin/:id/upload-bundle` (or `teable-plugin deploy`, which
|
||||
> handles all assets). It runs in the same React tree as the host and has
|
||||
> full access to the host's SDK, UI library, and OpenAPI helpers.
|
||||
>
|
||||
> **Dev loop**: `teable-plugin dev` (or `pnpm dev` in scaffolded projects)
|
||||
> watch-builds and auto-uploads with a `devMode` manifest; the open host page
|
||||
> remounts the plugin within ~4s without a page refresh. See
|
||||
> `dynamic-plugins/dev-workflow.md`.
|
||||
>
|
||||
> **Full-page apps**: install at position `route` and the host serves the
|
||||
> plugin on `/base/<baseId>/p/<pluginId>` (whole viewport, own router OK —
|
||||
> HashRouter recommended).
|
||||
>
|
||||
> **Critical constraint**: no RUNTIME imports from `@teable/sdk`, `@teable/ui-lib`,
|
||||
> `@teable/openapi` — webpack maps them to `window.__TEABLE_HOST__.*` externals, so a
|
||||
> value import compiles to `undefined` at runtime. `import type { X } from '@teable/...'`
|
||||
> IS fine (erased at compile time). `react`/`react-dom` may be imported ONLY when your
|
||||
> webpack `externals` maps them to `window.__TEABLE_HOST__.React/ReactDOM` (see the
|
||||
> batch-edit-pro webpack config). For everything else, read from the host global:
|
||||
> `const { useFields } = window.__TEABLE_HOST__.SDK`. See
|
||||
> `@teable/openapi` — a value import via monorepo aliases inlines the whole
|
||||
> package source (via window-global externals it compiles to `undefined`).
|
||||
> `import type { X } from '@teable/...'` IS fine (erased at compile time).
|
||||
> `react`/`react-dom` may be imported when your webpack `externals` maps them
|
||||
> to `window.__TEABLE_HOST__.React/ReactDOM` (see the plugin-showcase webpack
|
||||
> config; `react/jsx-runtime` is intentionally bundled — element factories
|
||||
> only, hooks stay on the host singleton). For everything else, read from the
|
||||
> host global: `const { useFields } = window.__TEABLE_HOST__.SDK`. See
|
||||
> `dynamic-plugins/troubleshooting.md` Pitfall 1 for details and symptoms.
|
||||
|
||||
## 0. Two efficiency rules (read first)
|
||||
@@ -71,8 +84,9 @@ JS that executes in the host page and accesses host APIs through
|
||||
| 3. SDK API | `dynamic-plugins/sdk-api.md` | when writing plugin logic — definePlugin, PluginContext, events, storage, useSelection, native form embedding (`NativeForm` Tier-1 injection / `NativeFormScope`+`useNativeForm` Tier-2 kit), NativeGrid |
|
||||
| 3b. Native form quick answer | `dynamic-plugins/native-form.md` | **when the user ASKS whether/how to embed native field editors, custom form layout, or multi-step forms — read this FIRST and answer directly (consultative questions need no scaffold/build)** |
|
||||
| 4. Deployment | `dynamic-plugins/deployment.md` | when uploading + enabling + authority matrix permissions |
|
||||
| 5. Troubleshooting | `dynamic-plugins/troubleshooting.md` | before writing logic — common pitfalls (host globals, developer whitelist, AM, empty states) |
|
||||
| Ref: batch-edit-pro | `plugins/batch-edit-pro/` | the canonical example — contextMenu plugin, full React + SDK |
|
||||
| 5. Troubleshooting | `dynamic-plugins/troubleshooting.md` | before writing logic — common pitfalls (host globals, developer whitelist, AM, empty states, chunk-name flattening, terser banners) |
|
||||
| Ref: plugin-showcase | `plugins/plugin-showcase/` | the v2 example — lazy chunks + source maps + Tailwind + HashRouter route app |
|
||||
| Ref: batch-edit-pro | `plugins/batch-edit-pro/` | the classic example — single-file contextMenu plugin, full React + SDK |
|
||||
|
||||
## 3. Quick decision: which position?
|
||||
|
||||
|
||||
@@ -7,9 +7,18 @@ Uploading a bundle plugin, enabling it, and managing permissions.
|
||||
Upload the built bundle to the host. Uses `multipart/form-data`.
|
||||
The `pluginId` in the URL must match the `id` in `plugin.json`.
|
||||
|
||||
Preferred (handles entry + chunks + maps + css automatically):
|
||||
|
||||
```bash
|
||||
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> \
|
||||
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js deploy -d .
|
||||
```
|
||||
|
||||
Raw curl equivalent (legacy single-file 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" \
|
||||
@@ -17,34 +26,51 @@ curl -s -b "auth_session=$SESSION" \
|
||||
```
|
||||
|
||||
**Auth note**: Supports both session cookies (`auth_session`) and Personal Access
|
||||
Tokens (`Authorization: Bearer <PAT>`).
|
||||
Tokens (`Authorization: Bearer <PAT>`). Runtime asset GETs
|
||||
(`/api/plugin/:id/runtime/*`) are session-only (PAT gets 403 by design) —
|
||||
verify those with a signed cookie, not a PAT.
|
||||
|
||||
**Fields:**
|
||||
| Field | Required | Max size | Description |
|
||||
|---|---|---|---|
|
||||
| `js` | Yes | 5 MB | The IIFE bundle (`dist/main.js`) |
|
||||
| `css` | No | 1 MB | Optional stylesheet (`dist/styles.css`) |
|
||||
| `manifest` | Yes | 10 KB | `plugin.json` manifest |
|
||||
**Fields** (v2 multi-asset, since 2026-10):
|
||||
|
||||
**Returns** `201` with `{ id, name, version, ... }` — the plugin ID to use for
|
||||
enable and install.
|
||||
| Field | Required | Max count | Max size | Description |
|
||||
|---|---|---|---|---|
|
||||
| `js` | Yes | 20 | 5 MB each | Entry + lazy chunks. Entry = the file whose name matches `manifest.entry` (a lone differently-named js file still works — legacy fallback) |
|
||||
| `css` | No | 5 | 1 MB each | Stylesheets, concatenated under the `manifest.style` name |
|
||||
| `map` | No | 20 | 5 MB each | Source maps, served later as `application/json` |
|
||||
| `manifest` | Yes | 1 | 10 KB | `plugin.json` manifest |
|
||||
|
||||
**Registering a NEW plugin id**: `POST /api/plugin` (manifest as JSON body) —
|
||||
**admin-only** (PAT gets 403 `restricted_resource`). Uploading a bundle for an
|
||||
already-registered id only needs the `plugin_developer` whitelist. Deleting a
|
||||
plugin (`DELETE /api/plugin/:id`) is likewise admin-only. PAT-only automation
|
||||
should reuse existing registered ids, or ask an admin to register/delete.
|
||||
- **Total cap 15 MB** across all assets.
|
||||
- Chunk filenames must be **FLAT** (`x-<hash>.chunk.js`) — the multipart
|
||||
transport strips directory parts from filenames.
|
||||
- The bundle dir on disk (`<backend-cwd>/.assets/plugins/bundle/<pluginId>/`)
|
||||
is **wiped on every upload** — stale chunks are removed, and hand-patched
|
||||
files on disk are discarded.
|
||||
|
||||
**Returns** `{ pluginId, hash, size, assets }` — `assets` is the stored file
|
||||
count (1 for legacy single-file uploads).
|
||||
|
||||
**Registering a NEW plugin id**: `POST /api/plugin {"id": "<kebab-id>", ...}`
|
||||
— PAT-accessible (`@TokenAccess()` since 2026-10) and **custom ids require
|
||||
the `plugin_developer` whitelist**. Without an explicit `id` the server
|
||||
mints a `plgXXX` id, which CANNOT receive bundles (manifest id must equal
|
||||
the DB id). Deleting a plugin (`DELETE /api/plugin/:id`) remains admin-only.
|
||||
|
||||
**Security** (7-layer validation):
|
||||
1. Authentication — valid session cookie required (401 if missing)
|
||||
1. Authentication — valid session cookie or PAT required (401 if missing)
|
||||
2. Developer whitelist — `plugin_developer` table lookup (403 if not whitelisted)
|
||||
3. Rate limit — 10 uploads/min per user (429 if exceeded)
|
||||
4. Size limits — JS ≤ 5 MB, CSS ≤ 1 MB, manifest ≤ 10 KB
|
||||
5. Manifest validation — required fields, `id` regex `[a-z0-9-]+`
|
||||
6. Content validation — JS must be IIFE format (prefix `!function`/`(()=>`),
|
||||
rejects `<script>`/`eval`/`document.write` injection; CSS strips `expression()`/
|
||||
`@import`/`javascript:` URIs
|
||||
7. Integrity — SHA-256 hash stored in DB, verified at load time
|
||||
4. Size limits — JS/map ≤ 5 MB each, CSS ≤ 1 MB each, manifest ≤ 10 KB,
|
||||
15 MB total
|
||||
5. Manifest validation — required fields, `id` regex `[a-z0-9-]+`,
|
||||
`minAppVersion` ≤ host version
|
||||
6. Content validation — the ENTRY must be IIFE format (prefix `!function` /
|
||||
`(function` / `(() =>` / `var <name>=<iife>`); chunks get the
|
||||
forbidden-pattern check only (they are not IIFEs); JS rejects
|
||||
`<script>`/`document.write`/`javascript:`; CSS strips `expression()`/
|
||||
`@import`/`javascript:` URIs; maps are name/size checked only (they embed
|
||||
original source text — pattern checks would false-positive)
|
||||
7. Integrity — entry SHA-256 stored in DB for audit + PluginAuditLog row
|
||||
(uploader/IP/UA); note the loader does NOT re-verify the hash at load time
|
||||
|
||||
## Enable — `PUT /api/base/:baseId/plugin-config/:pluginId`
|
||||
|
||||
@@ -118,11 +144,28 @@ must call `ctx.addView(id, component)` to render the component as a view.
|
||||
Authority Matrix entries are auto-created for the new install (default
|
||||
denied for non-owner roles).
|
||||
|
||||
### Route
|
||||
### Route (full-page app, since 2026-10)
|
||||
|
||||
Route-type plugins registered via `ctx.addRoute(path, component)` are
|
||||
automatically available at the registered path — no install API needed.
|
||||
Upload + enable is sufficient.
|
||||
The host renders a dedicated page for the plugin at
|
||||
`/base/<baseId>/p/<pluginId>` — the whole main-area viewport inside the base
|
||||
layout. The plugin registers via `ctx.addRoute(id, component)` and typically
|
||||
brings its own router (HashRouter recommended, so Next.js routing is
|
||||
untouched).
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $TEABLE_PAT" -X POST \
|
||||
"http://localhost:3003/api/base/$BASE_ID/plugin-installs" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"pluginId":"$PLUGIN_ID","name":"My App","position":"route"}'
|
||||
```
|
||||
|
||||
Notes (verified 2026-10 on plugin-showcase):
|
||||
- `manifest.positions` must include `"route"`.
|
||||
- The page itself does not gate on plugin-config enablement — the URL is the
|
||||
access point. Follow the standard enable+install flow anyway so the plugin
|
||||
shows up in management UIs and future permission layers behave uniformly.
|
||||
- Host SDK hooks (e.g. `host.SDK.useBaseId()`) work on the route page because
|
||||
it renders inside BaseLayout's provider stack.
|
||||
|
||||
## Authority Matrix (plugin permissions)
|
||||
|
||||
@@ -173,23 +216,18 @@ BASE_ID="${BASE_ID:?set to a throwaway sandbox base, never a production base}"
|
||||
# Read plugin ID from manifest
|
||||
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$DIR/plugin.json'))['id'])")
|
||||
|
||||
# Get session
|
||||
# PAT preferred (upload-bundle accepts Bearer auth):
|
||||
AUTH=(-H "Authorization: Bearer ${PAT:?set PAT env}")
|
||||
# ...or session:
|
||||
SESSION=$(grep auth_session /tmp/cookie.txt 2>/dev/null | awk '{print $NF}')
|
||||
AUTH=(-b "auth_session=$SESSION")
|
||||
|
||||
# Upload (URL includes pluginId from manifest)
|
||||
curl -s "${AUTH[@]}" \
|
||||
-F "js=@$DIR/dist/main.js" \
|
||||
-F "css=@$DIR/dist/styles.css" \
|
||||
-F "manifest=@$DIR/plugin.json" \
|
||||
"$UPLOAD_URL/api/plugin/$PLUGIN_ID/upload-bundle"
|
||||
# Upload: teable-plugin deploy handles entry + chunks + maps + css.
|
||||
# Raw-curl equivalent for a single-file bundle:
|
||||
# curl -s -H "Authorization: Bearer $TEABLE_PAT" \
|
||||
# -F "js=@$DIR/dist/main.js" -F "css=@$DIR/dist/styles.css" \
|
||||
# -F "manifest=@$DIR/plugin.json" \
|
||||
# "$UPLOAD_URL/api/plugin/$PLUGIN_ID/upload-bundle"
|
||||
TEABLE_ENDPOINT="$UPLOAD_URL" TEABLE_PAT="${TEABLE_PAT:?set PAT env}" \
|
||||
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js deploy -d "$DIR"
|
||||
echo "Uploaded: $PLUGIN_ID"
|
||||
|
||||
# Enable
|
||||
curl -s "${AUTH[@]}" -X PUT \
|
||||
curl -s -H "Authorization: Bearer $TEABLE_PAT" -X PUT \
|
||||
"$UPLOAD_URL/api/base/$BASE_ID/plugin-config/$PLUGIN_ID" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"enabled":true,"tableIds":[]}' > /dev/null
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -49,11 +49,17 @@ gated by a developer whitelist and bundles are validated for security.
|
||||
→ 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
|
||||
@@ -117,32 +123,41 @@ Location: `apps/nextjs-app/src/features/app/components/plugin/host/event-bus.ts`
|
||||
|
||||
```
|
||||
dist/
|
||||
├── main.js # IIFE bundle (webpack output)
|
||||
└── styles.css # optional stylesheet
|
||||
plugin.json # manifest
|
||||
├── 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 these as externals, mapping to host globals:
|
||||
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',
|
||||
'@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',
|
||||
'react': 'window.__TEABLE_HOST__.React',
|
||||
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
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__` (recommended for IIFE)
|
||||
### 1. Through `window.__TEABLE_HOST__` (the only runtime path)
|
||||
|
||||
```javascript
|
||||
const host = window.__TEABLE_HOST__;
|
||||
@@ -153,17 +168,21 @@ 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.
|
||||
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. Through `@teable/plugin-sdk` (also works because webpack externals)
|
||||
### 2. `import type` from `@teable/*` (type-checking only)
|
||||
|
||||
```typescript
|
||||
import { definePlugin } from '@teable/plugin-sdk';
|
||||
import type { ITeableHost } from '@teable/plugin-sdk';
|
||||
export const host = window.__TEABLE_HOST__ as unknown as ITeableHost;
|
||||
```
|
||||
|
||||
At build time, webpack resolves this to `window.__TEABLE_HOST__.PluginSDK.definePlugin`.
|
||||
Both styles work — the externals config handles the mapping.
|
||||
`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
|
||||
|
||||
@@ -173,4 +192,4 @@ Both styles work — the externals config handles the mapping.
|
||||
| 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)` |
|
||||
| Route | Full-page app at `/base/:baseId/p/:pluginId` (whole viewport inside BaseLayout; own router OK, HashRouter recommended) | `ctx.addRoute(id, component)` |
|
||||
|
||||
@@ -59,9 +59,17 @@ ctx.addContextMenuAction(id: string, component: React.FC): Disposable;
|
||||
ctx.addPanel(id: string, component: React.FC): Disposable;
|
||||
ctx.addDashboardWidget(id: string, component: React.FC): Disposable;
|
||||
ctx.addView(id: string, component: React.FC): Disposable;
|
||||
ctx.addRoute(path: string, component: React.FC): Disposable;
|
||||
ctx.addRoute(id: string, component: React.FC): Disposable;
|
||||
```
|
||||
|
||||
**Route position** (full-page apps, since 2026-10): the component renders at
|
||||
`/base/<baseId>/p/<pluginId>` — the whole main-area viewport inside the base
|
||||
layout, so SDK hooks like `host.SDK.useBaseId()` work. Install the plugin at
|
||||
position `"route"` (`POST /api/base/:baseId/plugin-installs`) and include
|
||||
`"route"` in `manifest.positions`. The plugin brings its own router — use
|
||||
HashRouter (e.g. `react-router-dom` bundled as a normal npm dep) so Next.js
|
||||
routing is untouched. Working example: `plugins/plugin-showcase/src/App.tsx`.
|
||||
|
||||
### Events
|
||||
|
||||
#### `ctx.on(event, handler): Disposable`
|
||||
|
||||
@@ -104,3 +104,46 @@ If you're setting up the webpack config manually, make sure:
|
||||
- The plugin does NOT need `import React from 'react'` at the top when using
|
||||
`react-jsx` transform — JSX is auto-transpiled. But you still need
|
||||
`const React = host.React` for any explicit `React.createElement` calls.
|
||||
|
||||
## Pitfall 8: Chunk 404 at runtime — multipart strips directory names (2026-10)
|
||||
|
||||
`webpack output.chunkFilename: 'static/js/[name].chunk.js'` builds fine, the
|
||||
upload succeeds, but the browser 404s the chunk: multer/busboy flattens
|
||||
multipart filenames, so the server stored `469.chunk.js` while the bundle
|
||||
requests `static/js/469.chunk.js`.
|
||||
|
||||
**Fix**: keep chunk names FLAT — `chunkFilename: '[name]-[contenthash].chunk.js'`
|
||||
(contenthash prevents collisions). Public path still works:
|
||||
`output.publicPath: '/api/plugin/<id>/runtime/'`.
|
||||
|
||||
## Pitfall 9: `Bundle must be IIFE format` after a successful build
|
||||
|
||||
Three producer-side causes, all seen in the wild:
|
||||
1. **Terser license banner** — bundling any npm package with license comments
|
||||
makes webpack emit `/*! For license information... */` BEFORE `!function`.
|
||||
Fix: `optimization.minimizer: [new TerserPlugin({ extractComments: false })]`.
|
||||
2. **Development-mode webpack** output starts with `/******/` banner comments.
|
||||
The dev watch loop therefore builds with `--mode production`.
|
||||
3. **Vite/esbuild IIFE** output starts `var <name>=(function...` — the
|
||||
validator accepts it (since 2026-10); no post-processing needed.
|
||||
|
||||
## Pitfall 10: vite scaffold `pnpm install` fails with ERR_PNPM_IGNORED_BUILDS
|
||||
|
||||
pnpm ≥ 10/11 blocks esbuild's postinstall and (on patched hosts) treats it as
|
||||
fatal. Fix (scaffold generates it): `pnpm-workspace.yaml` with
|
||||
`allowBuilds:\n esbuild: true`. esbuild also works without its script (binary
|
||||
comes from optionalDependencies) — only the pnpm gate needs the allow entry.
|
||||
|
||||
## Pitfall 11: PAT gets 403 fetching `/api/plugin/:id/runtime/*`
|
||||
|
||||
Access tokens only pass routes with `@TokenAccess()` or `@Permissions` — the
|
||||
runtime asset serving has neither. This is by design (the browser loads with a
|
||||
session). For curl verification use a signed `auth_session` cookie; for uploads
|
||||
use the PAT (`upload-bundle` and `POST /api/plugin` are token-accessible).
|
||||
|
||||
## Pitfall 12: "react/jsx-runtime would duplicate React" worry
|
||||
|
||||
It does not. The jsx automatic transform imports `react/jsx-runtime`, which is
|
||||
bundled into the plugin on purpose: it only creates plain element objects.
|
||||
Hooks/context/state go through the externalized `react` → host singleton. This
|
||||
is exactly how the deployed ticket-console/batch-edit-pro bundles behave.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"organization": "inkreach",
|
||||
"authors": ["inkreach"],
|
||||
"tags": ["plugin", "teable", "zip", "bridge", "context-menu", "dashboard", "panel", "view", "iframe"],
|
||||
"abstract": "Develop ZIP plugins for inkreach-teable: 4 mount positions, penpal bridge API, AI-friendly templates and examples."
|
||||
"tags": ["plugin", "teable", "bundle", "iife", "webpack", "vite", "context-menu", "dashboard", "panel", "view", "route", "hot-reload", "source-map"],
|
||||
"abstract": "Develop dynamic React component plugins for inkreach-teable: IIFE bundles (webpack or vite) loaded at runtime via window.__TEABLE_HOST__ with 5 mount positions including full-page route apps, multi-asset uploads (lazy chunks + source maps), a watch-and-hot-reload dev loop, and the teable-plugin CLI scaffold."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user