mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-10 08:27:40 +00:00
docs(plugins): update README for ES module frontend
Replace iframe-based plugin documentation with ES module approach. Update architecture diagram, file structure, and manifest.json examples to reflect the switch from index.html/iframe to index.js/module with mount/unmount exports and api.rpc() helper.
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# Hello World Plugin
|
||||
|
||||
A minimal example showing how to build a plugin for Claude Code UI, covering both frontend (iframe) and backend (server subprocess) capabilities.
|
||||
A minimal example showing how to build a plugin for Claude Code UI, covering both frontend (ES module) and backend (server subprocess) capabilities.
|
||||
|
||||
## How plugins work
|
||||
|
||||
A plugin's UI runs client-side inside a sandboxed iframe. Plugins can optionally declare a `server` entry in their manifest — a Node.js script that the host runs as a subprocess. The iframe communicates with its server through a postMessage RPC bridge (the host proxies the calls because the sandboxed iframe has no auth token).
|
||||
A plugin's UI is a plain ES module loaded directly into the host app — no iframe. Plugins can optionally declare a `server` entry in their manifest — a Node.js script that the host runs as a subprocess. The module calls the server through an `api.rpc()` helper (the host proxies the calls using its own auth token).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
@@ -30,10 +30,11 @@ A plugin's UI runs client-side inside a sandboxed iframe. Plugins can optionally
|
||||
┌───────────▼─────────────────────────▼───────────────────┐
|
||||
│ Frontend (browser) │
|
||||
│ │
|
||||
│ Plugin iframe ◄──postMessage──► Host app │
|
||||
│ (sandboxed) ccui:context │
|
||||
│ ccui:request-context │
|
||||
│ ccui:rpc / ccui:rpc-response │
|
||||
│ Plugin module (index.js) │
|
||||
│ import(url) → mount(container, api) │
|
||||
│ api.context — theme / project / session │
|
||||
│ api.onContextChange — subscribe to changes │
|
||||
│ api.rpc(method, path, body) → Promise │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -42,12 +43,12 @@ A plugin's UI runs client-side inside a sandboxed iframe. Plugins can optionally
|
||||
```
|
||||
my-plugin/
|
||||
manifest.json # Required — plugin metadata
|
||||
index.html # Frontend entry point (rendered in iframe)
|
||||
index.js # Frontend entry point (ES module, mount/unmount exports)
|
||||
server.js # Optional — backend entry point (runs as subprocess)
|
||||
package.json # Optional — npm dependencies for server.js
|
||||
```
|
||||
|
||||
All files in the plugin directory are accessible via `/api/plugins/:name/assets/`. Use relative paths in your HTML (e.g., `<link href="styles.css">`).
|
||||
All files in the plugin directory are accessible via `/api/plugins/:name/assets/`.
|
||||
|
||||
## manifest.json
|
||||
|
||||
@@ -59,11 +60,11 @@ All files in the plugin directory are accessible via `/api/plugins/:name/assets/
|
||||
"description": "Short description shown in settings.",
|
||||
"author": "Your Name",
|
||||
"icon": "Puzzle", // Lucide icon name (see available icons below)
|
||||
"type": "iframe", // Rendering method — only "iframe" is supported today
|
||||
"type": "module", // "module" (default) or "iframe" (legacy)
|
||||
"slot": "tab", // Where the plugin appears — only "tab" is supported today
|
||||
"entry": "index.html", // Frontend entry file, relative to plugin directory
|
||||
"entry": "index.js", // Frontend entry file, relative to plugin directory
|
||||
"server": "server.js", // Optional — backend entry file, runs as Node.js subprocess
|
||||
"permissions": [] // Reserved for future use
|
||||
"permissions": [] // Reserved for future use
|
||||
}
|
||||
```
|
||||
|
||||
@@ -79,6 +80,56 @@ All files in the plugin directory are accessible via `/api/plugins/:name/assets/
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Module API
|
||||
|
||||
The host dynamically imports your entry file and calls `mount(container, api)`. When the plugin tab is closed or the plugin is disabled, `unmount(container)` is called.
|
||||
|
||||
```js
|
||||
// index.js
|
||||
|
||||
export function mount(container, api) {
|
||||
// api.context — current snapshot: { theme, project, session }
|
||||
// api.onContextChange(cb) — subscribe, returns an unsubscribe function
|
||||
// api.rpc(method, path, body?) — call the plugin's server subprocess
|
||||
|
||||
container.innerHTML = '<p>Hello!</p>';
|
||||
|
||||
const unsub = api.onContextChange((ctx) => {
|
||||
container.style.background = ctx.theme === 'dark' ? '#111' : '#fff';
|
||||
});
|
||||
|
||||
container._cleanup = unsub;
|
||||
}
|
||||
|
||||
export function unmount(container) {
|
||||
if (typeof container._cleanup === 'function') container._cleanup();
|
||||
container.innerHTML = '';
|
||||
}
|
||||
```
|
||||
|
||||
### Context object
|
||||
|
||||
```js
|
||||
api.context // always up to date
|
||||
// {
|
||||
// theme: "dark" | "light",
|
||||
// project: { name: string, path: string } | null,
|
||||
// session: { id: string, title: string } | null,
|
||||
// }
|
||||
```
|
||||
|
||||
### RPC helper
|
||||
|
||||
```js
|
||||
// Calls /api/plugins/:name/rpc/hello via the host's authenticated fetch
|
||||
const data = await api.rpc('GET', '/hello');
|
||||
|
||||
// With a JSON body
|
||||
const result = await api.rpc('POST', '/echo', { greeting: 'hi' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend — Server subprocess
|
||||
|
||||
Plugins that need to make authenticated API calls, use npm packages, or run Node.js logic can declare a `"server"` entry in their manifest. The host manages the full lifecycle:
|
||||
@@ -140,70 +191,15 @@ server.listen(0, '127.0.0.1', () => {
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Context API
|
||||
|
||||
The host app sends a `ccui:context` message to the iframe whenever the theme, project, or session changes:
|
||||
|
||||
```js
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data?.type !== 'ccui:context') return;
|
||||
|
||||
const { theme, project, session } = event.data;
|
||||
// theme — "dark" | "light"
|
||||
// project — { name, path } | null
|
||||
// session — { id, title } | null
|
||||
});
|
||||
```
|
||||
|
||||
To request the current context on load:
|
||||
|
||||
```js
|
||||
window.parent.postMessage({ type: 'ccui:request-context' }, '*');
|
||||
```
|
||||
|
||||
## Frontend — RPC bridge
|
||||
|
||||
The sandboxed iframe cannot make authenticated API calls directly. Instead, it sends RPC requests via `postMessage`, and the host proxies them to the plugin's server subprocess:
|
||||
|
||||
```js
|
||||
function callBackend(method, path, body) {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = Math.random().toString(36).slice(2);
|
||||
|
||||
function handler(event) {
|
||||
if (event.data?.type === 'ccui:rpc-response' && event.data.requestId === requestId) {
|
||||
window.removeEventListener('message', handler);
|
||||
resolve(event.data);
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', handler);
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'ccui:rpc',
|
||||
requestId,
|
||||
method,
|
||||
path,
|
||||
body: body || undefined,
|
||||
}, '*');
|
||||
});
|
||||
}
|
||||
|
||||
// Usage
|
||||
const result = await callBackend('GET', '/hello');
|
||||
// result = { type: 'ccui:rpc-response', requestId, status: 200, data: { message: 'Hello!' } }
|
||||
```
|
||||
|
||||
The host receives `ccui:rpc`, makes an authenticated fetch to `/api/plugins/:name/rpc/*`, and returns the response as `ccui:rpc-response`.
|
||||
|
||||
## Frontend — Mobile
|
||||
|
||||
On desktop, each enabled plugin gets its own tab in the tab bar. On mobile, plugins are grouped under a single "More" button in the bottom navigation to save space.
|
||||
|
||||
## Security
|
||||
|
||||
### Frontend sandbox
|
||||
### Frontend isolation
|
||||
|
||||
Plugins run in a sandboxed iframe with `allow-scripts allow-forms allow-popups`. They **cannot** access the host app's cookies, localStorage, or auth tokens (`allow-same-origin` is intentionally omitted).
|
||||
Plugin modules run in the same JS context as the host app but have no access to auth tokens or internal state — only the `api` object passed to `mount`. They cannot make authenticated API calls directly; all server communication goes through `api.rpc()`, which the host proxies.
|
||||
|
||||
### Server subprocess isolation
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ export default function PluginTabContent({
|
||||
selectedSession,
|
||||
}: PluginTabContentProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const { isDarkMode } = useTheme();
|
||||
const { plugins } = usePlugins();
|
||||
|
||||
@@ -49,8 +48,6 @@ export default function PluginTabContent({
|
||||
const moduleRef = useRef<any>(null);
|
||||
|
||||
const plugin = plugins.find(p => p.name === pluginName);
|
||||
// 'iframe' is the explicit legacy type; everything else (including 'module' and unset) uses module loading
|
||||
const isIframe = plugin?.type === 'iframe';
|
||||
|
||||
// Keep contextRef current and notify the mounted plugin on every context change
|
||||
useEffect(() => {
|
||||
@@ -60,16 +57,10 @@ export default function PluginTabContent({
|
||||
for (const cb of contextCallbacksRef.current) {
|
||||
try { cb(ctx); } catch { /* plugin error — ignore */ }
|
||||
}
|
||||
}, [isDarkMode, selectedProject, selectedSession]);
|
||||
|
||||
// Also push to legacy iframe plugin
|
||||
if (isIframe && iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage({ type: 'ccui:context', ...ctx }, '*');
|
||||
}
|
||||
}, [isDarkMode, selectedProject, selectedSession, isIframe]);
|
||||
|
||||
// ── Module plugin (default) ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (isIframe || !containerRef.current) return;
|
||||
if (!containerRef.current) return;
|
||||
|
||||
let active = true;
|
||||
const container = containerRef.current;
|
||||
@@ -121,68 +112,7 @@ export default function PluginTabContent({
|
||||
contextCallbacksRef.current.clear();
|
||||
moduleRef.current = null;
|
||||
};
|
||||
}, [pluginName, isIframe, plugin?.entry]); // re-mount only when the plugin itself changes
|
||||
|
||||
// ── Legacy iframe plugin ─────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!isIframe) return;
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
if (!event.data || typeof event.data !== 'object') return;
|
||||
|
||||
const { type } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case 'ccui:request-context':
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: 'ccui:context', ...contextRef.current },
|
||||
'*',
|
||||
);
|
||||
break;
|
||||
|
||||
case 'ccui:rpc': {
|
||||
const { requestId, method, path: rpcPath, body } = event.data;
|
||||
if (!requestId || !rpcPath) break;
|
||||
const cleanPath = String(rpcPath).replace(/^\//, '');
|
||||
authenticatedFetch(`/api/plugins/${encodeURIComponent(pluginName)}/rpc/${cleanPath}`, {
|
||||
method: method || 'GET',
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
const data = await res.json().catch(() => null);
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: 'ccui:rpc-response', requestId, status: res.status, data }, '*',
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: 'ccui:rpc-response', requestId, status: 500, error: (err as Error).message }, '*',
|
||||
);
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [isIframe, pluginName]);
|
||||
|
||||
if (isIframe) {
|
||||
const src = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${plugin?.entry ?? 'index.html'}`;
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
title={`Plugin: ${pluginName}`}
|
||||
className="w-full h-full border-0"
|
||||
sandbox="allow-scripts allow-forms allow-popups"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [pluginName, plugin?.entry]); // re-mount only when the plugin itself changes
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full overflow-auto" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user