npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@loftnode/react

v0.0.22-dev

Published

Official React SDK for rendering LoftNode BYOC plugins.

Downloads

130

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.
  • LoftNodeProvider only 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; any is not used in the public API

Installation

npm install @loftnode/react @tanstack/react-query

or:

bun add @loftnode/react @tanstack/react-query

Peer 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.1

Latest Version

<LoftNodeProvider
    plugins={[
        {
            slug: "reviews",
            version: "latest",
        },
    ]}
>
    {children}
</LoftNodeProvider>

The SDK first resolves the current published version:

GET https://www.loftnode.com/api/plugins/reviews

It 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 DOMContentLoaded listener 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: none is applied to empty areas, and pointer-events: auto is 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/:slug route 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/:filePath

At 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:

  1. entrypoints.styles is added to the closed Shadow Root in parallel.
  2. entrypoints.scripts is executed in the order of the manifest.
  3. 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 next

In 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_id contains no PII and is randomly generated using Web Crypto.
  • The identity is preserved in sessionStorage within 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} -&gt;{" "}
            {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.com

The 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, and connect-src rules 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 build

Build runs the Vite library bundle and strict TypeScript declaration generation together.