Foxel
Menu
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.

tsx
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();
  },
});
MethodWhen calledContext
mount(container, ctx)Opening an associated filefilePath, entry, urls, host
mountApp(container, ctx)Opening an app from the plugin pageOnly host; no file path
unmount(container)File-mode cleanup fallback when mount returns no cleanupContainer
unmountApp(container)App-mode cleanup fallback when mountApp returns no cleanupContainer

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

typescript
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

MethodResult / 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

typescript
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.

typescript
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.

typescript
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 / featureHost globalBuild rule
react__FOXEL_EXTERNALS__.Reactexternal
react-dom, react-dom/client__FOXEL_EXTERNALS__.ReactDOMexternal; provides createRoot
antd__FOXEL_EXTERNALS__.antdexternal when used
@ant-design/icons__FOXEL_EXTERNALS__.AntdIconsexternal when used
react/jsx-runtimeNo corresponding globalKeep bundled, as official plugins do
MonacoMonacoEditorDeclare monaco, then read the global
ArtplayerArtplayerDeclare artplayer, then read the global
MarkdownReactMarkdownDeclare 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:

json
{ "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