@loftnode/react
v0.0.22-dev
Published
Official React SDK for rendering LoftNode BYOC plugins.
Downloads
130
Maintainers
Readme
@loftnode/react
Official React SDK for running LoftNode BYOC plugins in React 19 and Next.js 16 applications.
The SDK provides two distinct responsibilities:
usePlugins()lists and searches the public LoftNode catalog.LoftNodeProvideronly loads plugins explicitly installed by the host application.
The Provider does not automatically select plugins from the catalog. This separation prevents a newly published remote plugin from automatically executing code in the host application.
Features
- React Query-based catalog, manifest cache, and request deduplication
- Exact version pinning or explicit
"latest"version resolution - Independent closed Shadow DOM for each plugin
- Ordered asset loading for shared stylesheets, scripts, and slot hooks
- Global universal entrypoint mount independent of slot hooks
- Public Delivery API URLs and SHA-256-based Subresource Integrity
- Plugin-based allowlist host action bus
- Anonymous heartbeat deduplication per session
- Complete unmount cleanup based on AbortController
- Strict TypeScript;
anyis not used in the public API
Installation
npm install @loftnode/react @tanstack/react-queryor:
bun add @loftnode/react @tanstack/react-queryPeer dependencies:
{
"@tanstack/react-query": "^5.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
}React Query Setup
usePlugins() uses the standard React Query context. A QueryClientProvider must exist at the top client boundary of the application.
"use client";
import {
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
export function AppQueryProvider({
children,
}: {
children: ReactNode;
}) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}Next.js App Router layout:
import { AppQueryProvider } from "./AppQueryProvider";
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body>
<AppQueryProvider>
{children}
</AppQueryProvider>
</body>
</html>
);
}Plugin Catalog
usePlugins() calls the following public endpoint:
GET https://www.loftnode.com/api/catalog/plugins"use client";
import { usePlugins } from "@loftnode/react";
export function PluginCatalog() {
const query = usePlugins({
page: 1,
limit: 24,
search: "checkout",
type: "ui_injection",
sort: "popular",
verifiedAuthor: true,
minLss: 7,
});
if (query.isPending) {
return <p>Loading plugins...</p>;
}
if (query.error) {
return <p role="alert">{query.error.message}</p>;
}
return (
<ul>
{query.data.data.map((plugin) => (
<li key={plugin.id}>
{plugin.name}
{plugin.latest_version
? ` @ ${plugin.latest_version.version}`
: " - no published version"}
</li>
))}
</ul>
);
}Supported options:
interface PluginCatalogQueryOptions {
readonly page?: number;
readonly limit?: number;
readonly search?: string;
readonly category?: string;
readonly tag?: string;
readonly type?: "ui_injection" | "server_logic" | "hybrid";
readonly sort?: "recent" | "popular" | "alpha";
readonly verifiedAuthor?: boolean;
readonly minLss?: number;
readonly maxLss?: number;
readonly enabled?: boolean;
}The hook returns the raw paginated response:
interface RegistryPluginCatalogResponse {
readonly data: readonly RegistryCatalogPlugin[];
readonly pagination: {
readonly limit: number;
readonly page: number;
readonly total: number;
};
}Filtering, user selection, and persistent installation records are the responsibility of the host application.
Plugin Details
usePlugin(slug) (and equivalent usePluginDetail(slug)) fetches plugin details from the following endpoint:
GET https://www.loftnode.com/api/plugins/:slug"use client";
import { usePlugin } from "@loftnode/react";
export function PluginDetail({ slug }: { slug: string }) {
const query = usePlugin(slug);
if (query.isPending) return <p>Loading plugin...</p>;
if (query.error) return <p role="alert">{query.error.message}</p>;
return (
<article>
<h1>{query.data.name}</h1>
<p>{query.data.description ?? query.data.summary}</p>
<p>Latest version: {query.data.latest_version?.version ?? "None"}</p>
</article>
);
}Plugin reviews can be paginated separately:
import { usePluginReviews } from "@loftnode/react";
const reviews = usePluginReviews("discord-live-chat", {
page: 1,
limit: 10,
});The hook calls the GET /api/plugins/:slug/reviews endpoint and returns the summary, pagination, and actual review records.
In a Server Component, Route Handler, or other Node.js flow, a server function can be used instead of a hook:
import { getLoftNodePluginDetail } from "@loftnode/react/next";
export default async function PluginPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const plugin = await getLoftNodePluginDetail(slug);
return <h1>{plugin.name}</h1>;
}Provider
Passing plugins to the Provider is mandatory. An empty array is valid and will not trigger any network requests or generate any plugin code.
"use client";
import {
defineLoftNodeSlots,
LoftNodeProvider,
LoftNodeSlot,
type HostActions,
} from "@loftnode/react";
const loftnodeSlots = defineLoftNodeSlots([
{
name: "checkout-bottom",
label: "Checkout bottom area",
page: "/checkout",
},
{
name: "product-footer",
label: "Product detail bottom area",
page: "/products/[slug]",
},
]);
const hostActions = {
updateUser: async (
payload: { readonly displayName: string },
) => {
// Host application's own API call.
return { updated: true, name: payload.displayName };
},
} satisfies HostActions;
export function CheckoutIntegrations() {
return (
<LoftNodeProvider
plugins={[
{
slug: "reviews",
version: "2.4.1",
},
{
slug: "support",
version: "latest",
},
]}
hostActions={hostActions}
slots={loftnodeSlots}
>
<LoftNodeSlot
name="checkout-bottom"
loadingFallback={<p>Loading plugins...</p>}
fallback={(error) => (
<p role="alert">{error.message}</p>
)}
/>
</LoftNodeProvider>
);
}Provider props:
| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| plugins | readonly LoftNodePluginConfiguration[] | Yes | The list of plugins and versions allowed to run. |
| apiBaseUrl | string | No | Base URL for LoftNode registry/public API requests such as manifest resolution and heartbeat. Defaults to https://www.loftnode.com. Custom values must be HTTPS, except localhost development URLs. |
| runtimeApiBaseUrl | string | No | Base URL for the host BYOC runtime API created by createLoftNodeAppRouteHandler / createLoftNodeUniversalRouteHandlers. Defaults to /api/loftnode on the current origin. |
| hostActions | HostActions | No | Default list of host capabilities that plugins can call. |
| nonce | string | No | Applied to script, link, and style elements created by the SDK. |
| queryClient | QueryClient | No | The existing client to be used for Provider manifest queries. |
| disableUniversalMount | boolean | No | Disables the automatic universal entrypoint mount at the Provider level. |
| forceMountPlugins | readonly string[] | No | List of plugin keys or slugs to mount even when universal mount is disabled. |
| slots | LoftNodeSlotRegistry \| readonly LoftNodeSlotDefinition[] | No | Canonical UI slot list supported by the host application. |
| children | ReactNode | Yes | The React tree under the Provider. |
Plugin configuration:
interface LoftNodePluginConfiguration {
readonly key?: string;
readonly slug: string;
readonly version: string;
readonly manifest?: RegistryPluginVersionDetail;
readonly hostActions?: HostActions;
}version must be an exact published version or the literal "latest".
If manifest is provided, the SDK will not fetch version details from LoftNode during render; instead, it uses the local runtime manifest generated from the host application's own storage.
Custom API Hosts
Use apiBaseUrl when your LoftNode registry/public API is not served from https://www.loftnode.com.
Use runtimeApiBaseUrl when the BYOC runtime route is mounted on a different origin or path than the default /api/loftnode.
<LoftNodeProvider
apiBaseUrl="https://loftnode.example.com"
runtimeApiBaseUrl="https://plugins.example.com/api/loftnode"
plugins={[
{
slug: "reviews",
version: "latest",
},
]}
>
{children}
</LoftNodeProvider>apiBaseUrl is used for public plugin metadata and heartbeat requests. runtimeApiBaseUrl is used when the SDK rewrites legacy plugin calls such as fetch("/api/plugins/:slug/run") to the host server runner endpoint, and when resolving relative runtime asset URLs.
Slot Registry and Compatibility
Use defineLoftNodeSlots() to define the slots supported by the host application in a single place. This list is read from the Provider context at runtime and can also be used in the admin/catalog interface to check if a plugin is supported by the host.
import {
defineLoftNodeSlots,
getLoftNodePluginCompatibility,
type RegistryCatalogPlugin,
} from "@loftnode/react";
export const loftnodeSlots = defineLoftNodeSlots(
[
{
name: "checkout-bottom",
label: "Checkout bottom area",
page: "/checkout",
},
{
name: "account-sidebar",
label: "Account sidebar",
page: "/account",
},
],
{
allowUniversalEntrypoints: true,
},
);
export function canInstallPlugin(plugin: RegistryCatalogPlugin) {
return getLoftNodePluginCompatibility({
manifest: plugin.latest_version?.manifest,
slots: loftnodeSlots,
});
}Catalog UI example:
const compatibility = canInstallPlugin(plugin);
return (
<button disabled={!compatibility.supported}>
{compatibility.supported
? "Install"
: compatibility.reason ?? "Not supported by this host"}
</button>
);The LoftNodePluginCompatibility output contains:
interface LoftNodePluginCompatibility {
readonly supported: boolean;
readonly declaredSlots: readonly string[];
readonly supportedSlots: readonly string[];
readonly unsupportedSlots: readonly string[];
readonly hasUniversalEntrypoints: boolean;
readonly universalEntrypointsAllowed: boolean;
readonly reason: string | null;
}To read the slot list within the runtime:
import { useLoftNodeSlots } from "@loftnode/react";
export function SupportedPluginSlots() {
const slots = useLoftNodeSlots();
return (
<ul>
{slots.map((slot) => (
<li key={slot.name}>{slot.label ?? slot.name}</li>
))}
</ul>
);
}If a <LoftNodeSlot /> that is not in the list is rendered while slots is provided to the Provider, the SDK will emit a warning in the developer console. This helps keep the catalog compatibility check consistent with the actual host slot contract.
Exact Version
<LoftNodeProvider
plugins={[
{
slug: "reviews",
version: "2.4.1",
},
]}
>
{children}
</LoftNodeProvider>The exact version is fetched in a single request:
GET https://www.loftnode.com/api/plugins/reviews/versions/2.4.1Latest Version
<LoftNodeProvider
plugins={[
{
slug: "reviews",
version: "latest",
},
]}
>
{children}
</LoftNodeProvider>The SDK first resolves the current published version:
GET https://www.loftnode.com/api/plugins/reviewsIt then calls the exact specific-version endpoint. The query cache key retains the requested "latest" channel, while the runtime context separately provides the resolved exact version.
Multiple Versions of the Same Plugin
If the same slug is used multiple times, a unique key must be provided:
<LoftNodeProvider
plugins={[
{
key: "reviews-stable",
slug: "reviews",
version: "2.4.1",
},
{
key: "reviews-next",
slug: "reviews",
version: "3.0.0",
},
]}
>
<LoftNodeSlot
name="product-footer"
plugin="reviews-stable"
/>
</LoftNodeProvider>Plugin-Based Host Actions
The hostActions in the plugin configuration overrides the Provider defaults:
<LoftNodeProvider
hostActions={commonActions}
plugins={[
{
slug: "reviews",
version: "2.4.1",
},
{
slug: "admin-tools",
version: "1.0.0",
hostActions: adminActions,
},
]}
>
{children}
</LoftNodeProvider>Universal Entry Points
Some plugins do not provide a specific slot; instead, they declare only a global entrypoint in the manifest:
{
"ui_hooks": {},
"entrypoints": {
"styles": ["/main.css"],
"scripts": ["/main.js"]
}
}In this case, the Provider mounts the entrypoints.styles and entrypoints.scripts once inside a closed Shadow DOM when the plugin manifest is loaded. This behavior is used for UI add-ons that do not require slots, such as live chat, floating widgets, and global overlays.
Universal mount can be disabled based on routes. This is particularly useful for host pages where the plugin UI should not appear, such as admin panels, dashboards, and config pages:
<LoftNodeProvider
disableUniversalMount={pathname.startsWith("/admin")}
forceMountPlugins={["site-announcement"]}
plugins={installedPlugins}
slots={loftnodeSlots}
>
{children}
</LoftNodeProvider>disableUniversalMount only disables the Provider's automatic universal entrypoint mount behavior; explicit <LoftNodeSlot /> renders are unaffected.
forceMountPlugins values match the plugin key or slug and override both disableUniversalMount and the slot registry's allowUniversalEntrypoints: false decision for the respective plugin.
If ui_hooks is populated in the manifest, the entrypoints will continue to load as shared assets during slot render and will not be re-executed at the Provider level.
For legacy vanilla script compatibility, universal mount provides two bridges:
- If the plugin script registers a
DOMContentLoadedlistener after the page has loaded, the callback is executed once in a controlled manner. If the page is still loading, the listener binds to the native event, but the callback still runs within the SDK bridge. - If the plugin script or plugin-owned event callbacks use
document.body.appendChild(...)/insertBefore(...), the corresponding node is redirected to the plugin's closed Shadow DOM portal area. - The Universal Shadow Host stands as a fixed overlay at the viewport level;
pointer-events: noneis applied to empty areas, andpointer-events: autois applied to plugin nodes. This allows floating buttons, toasts, modals, and popup UIs to be visible without being trapped within the host layout. - Universal scripts are executed in the document head, while CSS and plugin DOM remain in the closed Shadow DOM.
- If legacy packages call
fetch("/api/plugins/:slug/run"), the SDK redirects this to the/api/loftnode/server-run/:slugroute inside the plugin callback.
This allows old packages, like the one below, to run without leaking CSS into the host DOM:
document.addEventListener("DOMContentLoaded", () => {
const button = document.createElement("button");
button.className = "voilabs-discord-btn";
document.body.appendChild(button);
});Admin Config UI
config_ui_entrypoint is the HTML interface provided by the plugin to gather settings in the admin panel. The SDK renders this inside a sandboxed iframe and routes config messages from the iframe to the host application's callback.
The Config UI is only opened from a same-origin asset URL found in the host application's local runtime manifest. URLs like https://www.loftnode.com/... or public delivery URLs are not loaded into the iframe; the config HTML must be fetched into the host storage during installation and served via a local URL such as /api/loftnode/assets/.../index.html.
Manifest example:
{
"config_ui_entrypoint": "/index.html",
"server_entrypoint": "/server.js",
"entrypoints": {
"styles": ["/main.css"],
"scripts": ["/main.js"]
}
}Admin side usage:
"use client";
import {
LoftNodeConfigPanel,
LoftNodeProvider,
} from "@loftnode/react";
export function PluginSettings() {
return (
<LoftNodeProvider
plugins={[
{
slug: "discord-live-chat",
version: "1.0.6",
},
]}
>
<LoftNodeConfigPanel
className="h-[420px] w-full rounded-lg border"
plugin="discord-live-chat"
onLoadConfig={async () => {
const response = await fetch(
"/api/loftnode/config/discord-live-chat",
);
const body = await response.json();
return body.config;
}}
onSaveConfig={async (payload) => {
await fetch(
"/api/loftnode/config/discord-live-chat",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
config: payload,
}),
},
);
}}
/>
</LoftNodeProvider>
);
}The config iframe accepts the following message types:
window.parent.postMessage({
type: "LOFTNODE_LOAD_CONFIG",
request_id: "initial"
}, "*");
window.parent.postMessage({
type: "LOFTNODE_SAVE_CONFIG",
request_id: "save-1",
payload: {
webhookUrl: "https://discord.com/api/webhooks/..."
}
}, "*");The SDK sends LOFTNODE_CONFIG_LOAD_RESULT and LOFTNODE_CONFIG_SAVE_RESULT responses to the iframe upon successful load/save operations. If a request_id is present, it is preserved in the response. For legacy packages, the aliases VOILABS_LOAD_CONFIG and VOILABS_SAVE_CONFIG are also accepted. The SDK only accepts messages originating from its own iframe; messages from other sources are ignored.
Private and Public Config
Plugin config is read from two different surfaces:
public: Safe areas that can be read by the config UI, frontend plugin code, or host admin screen.server: The full config, which can include sensitive fields like tokens/webhooks, sent to the runner only during/server-run.
Define this distinction using the getPluginConfiguration resolver inside createRuntime. If no resolver is provided, the storage adapter's getConfig() return value will continue to be used in both the config route and server-run for backward compatibility. Using a resolver is recommended in production.
import {
createRuntime,
type JsonObject,
} from "@loftnode/react";
function redactSecrets(config: JsonObject): JsonObject {
const output: Record<string, JsonObject[string]> = {};
for (const [key, value] of Object.entries(config)) {
if (/secret|token|password|webhook|api[-_]?key/i.test(key)) {
continue;
}
output[key] = value;
}
return output;
}
export const loftnodeRuntime = createRuntime({
storage: loftnodeStorage,
getContext: getLoftNodeRouteContext,
runner: loftnodeServerRunner,
async getPluginConfiguration({ plugin, context, scope }) {
const config =
(await loftnodeStorage.getConfig?.(
{ installId: plugin.id },
context,
))?.config ?? {};
if (scope === "server") {
return config;
}
const explicitPublicConfig = config.public;
return explicitPublicConfig &&
typeof explicitPublicConfig === "object" &&
!Array.isArray(explicitPublicConfig)
? explicitPublicConfig
: redactSecrets(config);
},
});For example, while the Discord webhook URL is kept server-only, settings needed on the frontend, like Google Tag Manager, can be written under public:
{
"webhookUrl": "https://discord.com/api/webhooks/...",
"public": {
"containerId": "GTM-XXXXXXX"
}
}The config UI must not assume that sensitive fields are reloaded after saving; if necessary, it should show a masked placeholder and leave the actual secret only to server-run.
Slot
LoftNodeSlot looks up the same slot name in the manifests of the plugins installed in the Provider:
<LoftNodeSlot name="checkout-bottom" />If multiple installed plugins provide the checkout-bottom hook, they are all rendered in separate Shadow DOMs.
A specific Provider key can be filtered:
<LoftNodeSlot
name="checkout-bottom"
plugin="reviews-stable"
/>Slot props:
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| name | string | Required | The ui_hooks key in the manifest. |
| plugin | string | All installed plugins | Optional Provider key filter. |
| className | string | None | Applied to the host element carrying the closed Shadow Root. |
| loadingFallback | ReactNode | null | Displayed while manifest queries are in progress. |
| fallback | ReactNode \| (error) => ReactNode | Internal error message | Query, asset, or runtime error. |
| onError | (error: Error) => void | None | Plugin Error Boundary callback. |
Manifest and Asset Loading
Important fields in the specific-version response:
{
"version": "2.4.1",
"manifest": {
"ui_hooks": {
"checkout-bottom": "/dist/checkout.js"
},
"entrypoints": {
"styles": ["/dist/plugin.css"],
"scripts": ["/dist/runtime.js"]
}
},
"assets": [
{
"file_path": "/dist/plugin.css",
"mime_type": "text/css",
"sha256": "...",
"url": "/api/delivery/v1/public/reviews/2.4.1/dist/plugin.css"
}
]
}The SDK matches each path with assets[].file_path and only loads the url field. In BYOC usage, this URL must be the host application's local route, e.g., /api/installed-plugins/{installId}/files/main.js. The s3_url is never written to the DOM.
In the registry install flow, the SDK normalizes markdown link formats ([/main.js](https://www.loftnode.com/main.js)) that may appear in legacy responses. If assets[].url returns a legacy CDN/S3 origin, it does not route directly to that origin; instead, it falls back to the canonical LoftNode public delivery path during installation:
https://www.loftnode.com/api/delivery/v1/public/:slug/:version/:filePathAt runtime, the plugin code still runs from the host storage, for example via /api/loftnode/assets/:installedPluginId/main.js.
Loading order for plugins with slot hooks:
entrypoints.stylesis added to the closed Shadow Root in parallel.entrypoints.scriptsis executed in the order of the manifest.- The
ui_hooks[slotName]script is executed.
If the same hook path is also present in entrypoints.scripts, it will not be executed twice. Asset records that are missing, duplicated, or contain path traversal will result in a controlled error sent to the plugin fallback.
Head Injections
manifest.head_injections is applied at the Provider level:
{
"head_injections": {
"meta_tags": [
{
"name": "robots",
"content": "index,follow"
}
],
"external_scripts": [
{
"src": "https://www.googletagmanager.com/gtm.js?id=GTM-XXXX",
"async": true
}
]
}
}The SDK adds meta and external script tags to document.head with ref-counting; it does not duplicate identical resources if they arrive again and cleans them up when the Provider unmounts. Head injections depend on global mount behavior: they are not applied when disableUniversalMount is active, and can be forced open for specific plugins using forceMountPlugins.
If ui_hooks is empty and entrypoints is present, the same style/script order is applied once in the Provider's universal mount. In this case, the plugin can run without requiring a LoftNodeSlot.
If the SHA-256 hex value is valid, the SDK converts it to a browser-compatible integrity="sha256-..." value and applies crossorigin="anonymous".
Shadow DOM Isolation
For each plugin/slot match:
attachShadow({ mode: "closed" })is used.- Host Tailwind selectors do not penetrate the plugin DOM.
- Plugin stylesheets do not leak into the host DOM or other plugins.
:host { all: initial; contain: style; isolation: isolate; }is applied.- An Error Boundary failure in one plugin does not crash other plugins.
Shadow DOM is a CSS and DOM encapsulation mechanism; it is not a complete security boundary for untrusted JavaScript. The host application must implement CSP, LoftNode LSS results, allowed host actions, and, if necessary, iframe/origin isolation.
Host Actions
The host application only exposes functions to plugins that it explicitly provides:
const hostActions = {
addToCart: async (
payload: {
readonly productId: string;
readonly quantity: number;
},
context,
) => {
console.log(context.pluginSlug, context.resolvedVersion);
return cartApi.add(payload);
},
} satisfies HostActions;Classic plugin script:
const script = document.currentScript;
const loftnode =
script?.loftnode ??
window.loftnode?.forInstance(
script?.dataset.loftnodeInstance ?? "",
);
if (!loftnode) {
throw new Error("LoftNode runtime is unavailable.");
}
const button = document.createElement("button");
button.textContent = "Add to cart";
button.addEventListener("click", async () => {
await loftnode.trigger("addToCart", {
productId: "product_123",
quantity: 1,
});
});
loftnode.mountElement.appendChild(button);Each action request is bound to the plugin instance's capability. Undefined, prototype-related, or invalid action names are rejected. Pending action calls are aborted with an AbortError during unmount.
BYOC Storage Adapter
The SDK does not assume where to save files. The host application provides a functional storage adapter for database, S3/R2, filesystem, or any custom service. The SDK handles validation, manifest/file mapping, route handler, and runtime manifest generation.
import {
createRouteContext,
createRuntime,
createServerRunner,
createStorage,
type JsonObject,
type JsonValue,
} from "@loftnode/react";
type LoftNodeHostContext = {
readonly userId: string;
readonly workspaceId: string;
};
const serverRunnerUrl =
process.env.LOFTNODE_SERVER_RUNNER_URL ??
"http://127.0.0.1:8787/run";
function toPublicPluginConfig(config: JsonObject): JsonObject {
const explicitPublicConfig = config.public;
if (
explicitPublicConfig &&
typeof explicitPublicConfig === "object" &&
!Array.isArray(explicitPublicConfig)
) {
return explicitPublicConfig;
}
return Object.fromEntries(
Object.entries(config).filter(
([key]) =>
!/secret|token|password|webhook|api[-_]?key/i.test(key),
),
) as JsonObject;
}
export const loftnodeStorage = createStorage<LoftNodeHostContext>({
async getInstalledPlugin(input, context) {
return db.installedPlugin.findFirst(/* user/workspace scoped */);
},
async upsertInstalledPlugin(input, context) {
return db.installedPlugin.upsert(/* ... */);
},
async putFile(input, context) {
return objectStorage.put(input);
},
async getFile(input, context) {
const metadata = await db.pluginFile.findFirst(/* scoped lookup */);
if (!metadata || input.includeBody === false) return metadata;
return { ...metadata, body: await objectStorage.get(metadata.path) };
},
async listFiles(input, context) {
const files = await db.pluginFile.findMany(/* scoped lookup */);
if (input.includeBody === false) return files;
return Promise.all(files.map(async (file) => ({
...file,
body: await objectStorage.get(file.path),
})));
},
async getConfig(input, context) {
return db.pluginConfig.findFirst(/* ... */);
},
async saveConfig(input, context) {
return db.pluginConfig.upsert(/* ... */);
},
});
export const getLoftNodeRouteContext =
createRouteContext<LoftNodeHostContext>(
async (request) => {
const user = await requireUser(request);
return {
userId: user.id,
workspaceId: user.activeWorkspaceId,
};
},
);
export const loftnodeServerRunner =
createServerRunner<LoftNodeHostContext>(
async ({ source, requestData, localSettings, plugin }, context) => {
const response = await fetch(serverRunnerUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
context,
localSettings,
plugin: {
slug: plugin.slug,
version: plugin.version,
},
requestData,
source,
}),
});
if (!response.ok) {
throw new Error(
`LoftNode server runner failed: ${response.status}`,
);
}
return (await response.json()) as JsonValue;
},
);
export const loftnodeRuntime = createRuntime({
storage: loftnodeStorage,
getContext: getLoftNodeRouteContext,
runner: loftnodeServerRunner,
async getPluginConfiguration({ plugin, context, scope }) {
const config =
(await loftnodeStorage.getConfig?.(
{ installId: plugin.id },
context,
))?.config ?? {};
return scope === "server" ? config : toPublicPluginConfig(config);
},
install: {
allowRegistryAssetDownload: false,
},
});The getFile and listFiles adapters must respect the includeBody flag. The SDK sends false in metadata-only endpoints like file lists to completely bypass remote object storage reads. If the flag is not provided, the body must be included for backward compatibility.
Factory helpers do not alter runtime behavior; they type-check adapters and functions according to the contract expected by the SDK, shallow-freeze adapter/runtime objects, and ensure a single loftnodeRuntime export is used in the route file.
Short factory names (createStorage, createRouteContext, createServerRunner, createRuntime) are the recommended root APIs and are imported directly from @loftnode/react. The long createLoftNode* names continue to be exported for backward compatibility and for projects preferring more explicit imports.
LOFTNODE_SERVER_RUNNER_URL is the isolated worker, VM, container, or sandbox service controlled by the host application. The SDK intentionally does not provide a default eval runner; running server.js code inside the main Next.js process is not recommended for production.
During installation, metadata is fetched from LoftNode, while the actual files come from the request or the admin upload flow:
import {
installLoftNodePlugin,
} from "@loftnode/react";
await installLoftNodePlugin({
context: { userId },
storage: loftnodeStorage,
slug: "discord-live-chat",
version: "1.0.6",
files: [
{
filePath: "/main.js",
mimeType: "text/javascript",
body: mainJsSource,
},
{
filePath: "/main.css",
mimeType: "text/css",
body: mainCssSource,
},
],
});installLoftNodePlugin does the following:
- Fetches version metadata from the public LoftNode API.
- Extracts required file paths from the manifest.
- Rejects missing, duplicated, or unsafe paths.
- Enforces file size limits.
- Validates SHA-256 checksums if present.
- Saves files using the provided storage adapter functions.
If the host application does not want admin uploads, it can download public LoftNode delivery assets during installation and copy them to its own storage:
import {
installLoftNodePluginFromRegistry,
} from "@loftnode/react";
await installLoftNodePluginFromRegistry({
context: { userId },
storage: loftnodeStorage,
slug: "discord-live-chat",
version: "1.0.6",
maxFileBytes: 1024 * 1024 * 5,
maxConcurrency: 6,
maxTotalBytes: 1024 * 1024 * 25,
});Asset downloads, checksum verifications, and storage writes run in a controlled parallel manner. maxConcurrency defaults to 6; it can be set between 1-32 depending on the adapter/infrastructure capacity.
This flow does not run plugin code from the LoftNode CDN at runtime; asset bodies are validated at install time and written to the host storage.
SDK Route Handler Factories
Route helpers return Web Request/Response objects, meaning they can be used directly inside Next.js App Router Route Handlers. The host application provides authentication and tenant information in the getContext function.
Universal Next Route
In most Next.js applications, a single catch-all route is sufficient:
// app/api/loftnode/[[...slugs]]/route.ts
import {
createLoftNodeAppRouteHandler,
} from "@loftnode/react/next";
import { loftnodeRuntime } from "@/lib/loftnode";
const handlers = createLoftNodeAppRouteHandler(loftnodeRuntime);
export const GET = handlers.GET;
export const POST = handlers.POST;
export const PATCH = handlers.PATCH;
export const DELETE = handlers.DELETE;This route dispatches the following endpoints:
| Method | Path | Behavior |
| --- | --- | --- |
| POST | /api/loftnode/install | Installation using uploaded files or registry asset copying. |
| GET | /api/loftnode/assets/:installedPluginId/:path* | Serves asset bodies from host storage. |
| GET/POST | /api/loftnode/config/:idOrSlug | Reads config, saves full replace, or applies a shallow patch. |
| POST | /api/loftnode/server-run/:idOrSlug | Hands the stored server_entrypoint resource over to the host runner. |
| GET | /api/loftnode/installed | Lists installed plugins. |
| GET/PATCH/DELETE | /api/loftnode/installed/:id | Installation details, enable/disable, and deletion. GET ?include=files returns the metadata list in the same response. |
| GET | /api/loftnode/installed/:id/files | Returns the metadata list of files stored in the installation. |
The CLI can scaffold the same route file:
npx @loftnode/react init nextIn projects using Pages Router:
// pages/api/loftnode/[[...slugs]].ts
import {
createLoftNodePagesApiHandler,
} from "@loftnode/react/next";
import { loftnodeRuntime } from "@/lib/loftnode";
export default createLoftNodePagesApiHandler(loftnodeRuntime);Granular route factories are still supported; applications wanting custom path designs can use the following helpers individually.
Local Asset Route
// app/api/installed-plugins/[installId]/files/[...path]/route.ts
import {
createLoftNodeAssetRouteHandler,
} from "@loftnode/react";
export const GET = createLoftNodeAssetRouteHandler({
storage: loftnodeStorage,
getContext: async () => ({ userId: await requireUserId() }),
getInstallId: (params) => params.installId,
getFilePath: (params) => `/${params.path.join("/")}`,
});Install Route
// app/api/installed-plugins/install/route.ts
import {
createLoftNodeInstallRouteHandler,
} from "@loftnode/react";
export const POST = createLoftNodeInstallRouteHandler({
storage: loftnodeStorage,
getContext: async () => ({ userId: await requireUserId() }),
readRequest: async (request) => {
// JSON, multipart, or custom upload flow can be completely customized.
return parseYourInstallRequest(request);
},
});Config Route
const handlers = createLoftNodeConfigRouteHandlers({
storage: loftnodeStorage,
getContext: async () => ({ userId: await requireUserId() }),
getInstallId: (params) => params.installId,
});
export const GET = handlers.GET;
export const POST = handlers.POST;Server Entrypoint Route
server_entrypoint is not fetched live from the LoftNode CDN. The copy in the storage adapter is read and passed to the runner function provided by the host.
import {
createLoftNodeServerRunRouteHandler,
} from "@loftnode/react";
import {
loftnodeServerRunner,
loftnodeStorage,
} from "@/lib/loftnode";
export const POST = createLoftNodeServerRunRouteHandler({
storage: loftnodeStorage,
getContext: async () => {
const user = await requireUser();
return {
userId: user.id,
workspaceId: user.activeWorkspaceId,
};
},
getInstalledPlugin: async (params, request, context) =>
loftnodeStorage.getInstalledPlugin(
{ slug: params.slug },
context,
),
runner: loftnodeServerRunner,
});The SDK intentionally does not provide a default eval runner. Production hosts should run server plugin code in their own VM, worker, container, or sandbox environments.
Local Runtime Manifest
A React runtime manifest can be generated from the files in storage:
import {
buildLoftNodeRuntimeManifest,
} from "@loftnode/react";
const manifest = buildLoftNodeRuntimeManifest({
plugin: installedPlugin,
files,
getFileUrl: (file, plugin) =>
`/api/installed-plugins/${plugin.id}/files${file.filePath}`,
});
<LoftNodeProvider
plugins={[
{
slug: installedPlugin.slug,
version: installedPlugin.version,
manifest,
},
]}
>
{children}
</LoftNodeProvider>;Heartbeat
After the manifest is resolved to an exact version, the SDK sends an anonymous request to the following public endpoint:
POST https://www.loftnode.com/api/plugins/:slug/heartbeat
Content-Type: application/json{
"version": "2.4.1",
"instance_id": "browser-random-session-id"
}instance_idcontains no PII and is randomly generated using Web Crypto.- The identity is preserved in
sessionStoragewithin the same browser session. - Only one attempt is made per session for the same
slug + resolvedVersion. - If storage access is unavailable, in-memory deduplication is used.
- Heartbeat failures do not affect the plugin manifest or the render flow.
Runtime Hooks
usePluginManifest
Queries a single exact or latest version outside the Provider:
const query = usePluginManifest("reviews", {
version: "2.4.1",
});This hook also uses the host QueryClientProvider context from the parent tree.
useLoftNode
Returns the runtime state of a single plugin inside the Provider:
function PluginStatus() {
const plugin = useLoftNode("reviews");
return (
<span>
{plugin.pluginSlug}:{" "}
{plugin.requestedVersion} ->{" "}
{plugin.resolvedVersion ?? "loading"}
</span>
);
}If the Provider contains multiple plugins, a key must be provided.
useLoftNodePlugins
Returns all runtime states installed in the Provider:
const plugins = useLoftNodePlugins();This hook does not represent all plugins in the catalog, but only the installations in the Provider's plugins prop.
React Query Behavior
Manifest query key:
["@loftnode/react", "plugin-version", slug, requestedVersion]Catalog query key:
["@loftnode/react", "catalog", serializedRequestPath]Defaults:
| Setting | Value |
| --- | --- |
| staleTime | 5 minutes |
| gcTime | 30 minutes |
| Deterministic 4xx | No retry |
| 429, 5xx, network | At most 2 retries |
| throwOnError | false |
| Cancellation | React Query AbortSignal API is passed to the client |
If no queryClient is provided to the Provider, it creates a private client bound to its own lifecycle. If provided, it uses the host client.
API Origin and Authentication
The SDK only uses the public LoftNode API surface:
https://www.loftnode.comThe SDK does not have apiKey, Bearer token, or cookie authentication. Fetch requests are sent with credentials: "omit".
Cleanup
When a plugin/slot unmounts, the SDK:
- Aborts in-progress asset loading operations.
- Removes script and stylesheet event handlers.
- Cleans up window error and unhandled rejection listeners.
- Cancels host action instance registration.
- Rejects pending host action Promises.
- Removes script, link, mount root, and strict style nodes.
- Cleans up the private QueryClient created by the SDK when the Provider unmounts.
Public Exports
export {
ApiCallError,
assertLoftNodePluginCompatibility,
buildLoftNodeRuntimeManifest,
createLoftNodeAssetRouteHandler,
createLoftNodeConfigRouteHandlers,
createLoftNodeInstallRouteHandler,
createLoftNodeRouteContext,
createLoftNodeRouteContextResolver,
createLoftNodeRuntime,
createLoftNodeRouteRuntime,
createLoftNodeServerRunner,
createLoftNodeStorage,
createLoftNodeStorageAdapter,
createLoftNodeServerRunRouteHandler,
createLoftNodeUniversalRouteHandlers,
createRouteContext,
createRouteContextResolver,
createRuntime,
createServerRunner,
createStorage,
createStorageAdapter,
defineLoftNodeSlots,
getLoftNodePluginCompatibility,
getLoftNodeSlotNames,
installLoftNodePlugin,
installLoftNodePluginFromRegistry,
LoftNodeHostConfigFrame,
LoftNodeHostRuntimeProvider,
LoftNodeHostSlot,
LoftNodeConfigPanel,
LoftNodeProvider,
LoftNodeSlot,
resolveLoftNodeServerEntrypoint,
useLoftNode,
useLoftNodePlugins,
useLoftNodeSlotRegistry,
useLoftNodeSlots,
usePluginManifest,
usePlugins,
};Next.js server-safe entrypoint:
import {
createLoftNodeAppRouteHandler,
createLoftNodePagesApiHandler,
} from "@loftnode/react/next";Import runtime factories from the root entrypoint:
import {
createStorage,
createRouteContext,
createServerRunner,
createRuntime,
} from "@loftnode/react";Main types:
import type {
HostAction,
HostActionContext,
HostActions,
LoftNodeConfigLoadContext,
LoftNodeConfigMessageContext,
LoftNodeConfigPanelProps,
LoftNodeConfigSaveContext,
LoftNodePluginCompatibility,
LoftNodePluginApi,
LoftNodeServerEntrypoint,
LoftNodeStorageAdapter,
LoftNodeInstalledPluginRecord,
LoftNodeMaybePromise,
LoftNodeStoredPluginFileRecord,
LoftNodePluginConfiguration,
LoftNodeProviderProps,
LoftNodeRouteContextResolver,
LoftNodeRouteRuntimeOptions,
LoftNodeServerRunner,
LoftNodeSlotDefinition,
LoftNodeSlotProps,
LoftNodeSlotRegistry,
PluginCatalogQueryOptions,
RegistryCatalogPlugin,
RegistryPluginCatalogResponse,
RegistryPluginDetail,
RegistryPluginManifest,
RegistryPluginVersionDetail,
} from "@loftnode/react";Security Checklist
- Keep plugin slugs and exact versions as an allowlist in the host database in production.
- Use the
"latest"option only if auto-update behavior is intentionally desired. - Restrict CSP
script-src,style-src, andconnect-srcrules to LoftNode/CDN origins. - Validate host action payloads with Zod, TypeBox, or equivalent runtime schemas.
- Re-apply authorization checks on the host backend.
- Explicitly display versions that are yanked or have a low LSS score on the installation screen.
- Run untrusted plugins in an iframe and separate origin.
Development
bun install
bun run buildBuild runs the Vite library bundle and strict TypeScript declaration generation together.
