On this page
Frontend Development
A frontend entry is an IIFE script executed in Foxel's same-origin iframe. React and native DOM implementations are both supported; both must call window.FoxelRegister(...).
Registration and lifecycle
This shows file and standalone entries. See web/src/plugins/sdk/v1.ts for the full contract and the complete example for a local contract.ts.
import { createRoot } from "react-dom/client";
import type { PluginContext, PluginAppContext } from "./contract";
const register = window.FoxelRegister;
if (!register) throw new Error("FoxelRegister is missing");
register({
mount(container: HTMLElement, ctx: PluginContext) {
const root = createRoot(container);
root.render(<div>{ctx.filePath}</div>);
return () => root.unmount();
},
mountApp(container: HTMLElement, ctx: PluginAppContext) {
const root = createRoot(container);
root.render(<button onClick={() => void ctx.host.close()}>Close</button>);
return () => root.unmount();
},
});
| Method | When called | Context |
|---|---|---|
mount(container, ctx) | Opening an associated file | filePath, entry, urls, host |
mountApp(container, ctx) | Opening an app from the plugin page | Only host; no file path |
unmount(container) | File-mode cleanup fallback when mount returns no cleanup | Container |
unmountApp(container) | App-mode cleanup fallback when mountApp returns no cleanup | Container |
The current RegisteredPlugin type requires mount. For a standalone-only frontend, keep a type-compatible mount and implement the UI in mountApp. Setting openApp: true does not generate that method.
Mounting can return a cleanup function synchronously or asynchronously. The host uses that function when provided, otherwise the corresponding unmount method; it does not run both. Unmount React roots, clear timers and subscriptions, stop media, release Object URLs, and handle pending asynchronous work.
Register immediately rather than waiting for file reads or lengthy initialization. The current registration timeout is 15 seconds.
File context
interface PluginContext {
filePath: string;
entry: {
name: string;
is_dir: boolean;
size: number;
mtime: number;
type?: string;
has_thumbnail?: boolean;
};
urls: { downloadUrl: string; streamUrl?: string };
host: FoxelHostApi;
}
filePath is a Foxel VFS path, such as /documents/example.json, rather than a server disk path. entry contains metadata. Prefer urls.downloadUrl or a temporary URL from host.getTempLink() for media: media elements cannot attach the API client's Bearer token themselves.
Host methods
| Method | Result / purpose |
|---|---|
host.close() | Promise<void>; close this window |
host.openPath?(path) | Promise<void>; navigate to a VFS path |
host.openFile?(filePath, appKey?) | Promise<void>; open a file, optionally with a chosen app |
host.showMessage(type, content) | success / error / info / warning notification |
host.callApi<T>(path, options?) | Authenticated request returning a parsed result |
host.getTempLink?(filePath) | Promise<string>; temporary public URL |
host.getStreamUrl?(filePath) | string; stream URL, subject to the request's authentication mechanism |
Methods marked ? are optional in the contract; check before calling. Host bridge methods close, openPath, and openFile can reject, so handle errors. Use absolute VFS paths /... without .., backslashes, or control characters.
Request paths and responses
const result = await ctx.host.callApi<{ valid: boolean }>(
"/com.example.jsonviewer/validate",
{ method: "POST", json: { content: "{}" } },
);
console.log(result.valid);
The client adds the /api base path. This reaches /api/com.example.jsonviewer/validate; do not pass /api/... again. window.__FOXEL_EXTERNALS__.foxelApi.request follows the same convention.
JSON responses normally return the parsed object. An envelope containing { "code": 0, "msg": "ok", "data": ... } (or message) is unwrapped to data. Having only code and data does not trigger unwrapping. Non-success HTTP responses throw. There is no generic foxelApi.plugins.call(...) RPC method; call your backend's HTTP routes.
VFS access
The injected foxelApi.vfs exposes a specific subset, rather than the entire frontend vfsApi object.
const externals = window.__FOXEL_EXTERNALS__;
if (!externals) throw new Error("Foxel externals are missing");
const vfs = externals.foxelApi.vfs;
const buffer = await vfs.readFile(ctx.filePath);
const text = new TextDecoder("utf-8").decode(buffer);
// Explicit save action; this writes to the selected VFS destination.
await vfs.uploadFile("/documents/copy.json", new Blob([text], {
type: "application/json",
}));
const listing = await vfs.list("/documents", 1, 50);
console.log(listing.entries, listing.pagination);
readFile(path) returns Promise<ArrayBuffer>; decode it yourself. uploadFile(fullPath, file) accepts File | Blob and a full VFS destination including the filename. list is paginated; inspect pagination instead of assuming one page contains everything. Access remains subject to the current user's path permissions.
Available methods are list, stat, mkdir, rename, deletePath, copy, move, streamUrl, getTempLinkToken, getTempPublicUrl, readFile, and uploadFile. The VFS method is streamUrl; getStreamUrl belongs to host.
const link = await vfs.getTempLinkToken(ctx.filePath);
const url = link.url || vfs.getTempPublicUrl(link.token);
Temporary links expire; do not persist them as permanent addresses. For other files, you can also use await ctx.host.getTempLink?.(filePath).
Shared dependencies and capabilities
| Import / feature | Host global | Build rule |
|---|---|---|
react | __FOXEL_EXTERNALS__.React | external |
react-dom, react-dom/client | __FOXEL_EXTERNALS__.ReactDOM | external; provides createRoot |
antd | __FOXEL_EXTERNALS__.antd | external when used |
@ant-design/icons | __FOXEL_EXTERNALS__.AntdIcons | external when used |
react/jsx-runtime | No corresponding global | Keep bundled, as official plugins do |
| Monaco | MonacoEditor | Declare monaco, then read the global |
| Artplayer | Artplayer | Declare artplayer, then read the global |
| Markdown | ReactMarkdown | Declare react-markdown, then read the global |
Do not bundle a second React while using host components: hooks may fail. Avoid broad external rules such as /^react/, which also exclude JSX subpaths the host does not expose. Declare optional capabilities in the manifest so they load before your script executes:
{ "frontend": { "capabilities": ["react-markdown"] } }
Merge this fragment into a complete manifest. Read window.__FOXEL_EXTERNALS__?.ReactMarkdown and check availability. A runtime loadCapability(name): Promise<void> is also exposed; do not cache an undefined component before loading finishes.
Styles, theme, and language
Declare emitted CSS in frontend.styles. Prefix selectors with your plugin name. Styles run inside the iframe; parent React providers do not cross the iframe boundary.
Wrap Ant Design UIs in your own ConfigProvider. For theme synchronization, see the official music plugin's theme.ts: observe the parent document's data-theme and theme variables, and disconnect observers on cleanup. System prefers-color-scheme alone does not reflect a manual Foxel theme choice.
Manifest i18n translates plugin metadata; UI text needs its own translations. The optional __FOXEL_EXTERNALS__.i18n offers getLang(), subscribe(callback), and create({ zh, en }), returning t and React useI18n. Clean up direct subscriptions.
Static resource URLs
The bundled frontend/icon.svg is served at /api/plugins/{key}/assets/frontend/icon.svg. Do not reference it as ./icon.svg, which resolves relative to the iframe page URL.
Resources under publicDir or listed in assets, styles, entry, or icon may be served. Python files, declared backend modules, manifest.json, and hidden paths remain blocked. Resource endpoints require no login; keep private data out of public directories.
The host adds ?v=<content_hash> to script and stylesheet URLs. For manually constructed resource URLs, use the plugin detail's content_hash as v. An outdated revision returns 409 after an update; reopen the plugin. frontend.assets declares public files but does not insert them into the page.
Next steps
- Build and package: complete Vite externals configuration.
- Complete example: JSON viewer with asynchronous cleanup.