@homerunner-next/widget-core
v0.6.5
Published
Shared types, URL helpers, plugin schema, runtime mount, and Vite tooling for HomeRunner widget plugins and host packages.
Downloads
1,270
Readme
@homerunner-next/widget-core
Shared SDK for HomeRunner widget plugins and host packages — types, URL helpers, plugin schema, runtime mount, and Vite tooling. The same primitives that system widgets use are exposed here so third-party plugins behave identically to first-party ones.
Install
npm install @homerunner-next/widget-corePeer deps (all optional, declared via peerDependenciesMeta):
react ^19.2.0
react-dom ^19.2.0
@tanstack/react-query ^5
zod ^3
vite ^5 || ^6 || ^7Install the peers your plugin actually uses. A pure urls consumer needs none; a runtime-mounting plugin needs React + react-dom + react-query.
Pin to the canonical runtime versions
The renderer's vm sandbox and the runtime IIFE on assets.homerunner.io ship a single
shared React build. Plugin client umds externalize react, react-dom,
react-dom/client, react-dom/server, react/jsx-runtime, and @tanstack/react-query
against that runtime — meaning a plugin's bundled React APIs must match the runtime's
exactly, not just by major version. Even a patch drift can trip the
Incompatible React versions runtime check inside react-dom.
Canonical versions are tracked in lavender/pnpm-workspace.yaml's catalog: block. As
of the current release:
| Package | Pin (exact) |
| --- | --- |
| react | 19.2.5 |
| react-dom | 19.2.5 |
| @tanstack/react-query | 5.95.2 |
Pin these exact values in your plugin's package.json (no caret). create-hr-plugin
scaffolds with these pins by default — keep them aligned when the catalog is bumped.
Browser-only side effects in useEffect
Plugin SSR umds bundle into a single file (Vite's inlineDynamicImports), so
dynamic import("...") calls are hoisted to module-evaluation time — they run
when the renderer loads the umd in its vm sandbox, not when the useEffect
callback fires. Guard browser-only side effects (DOM measurement, IntersectionObserver,
localStorage, third-party loaders like webfontloader) with an explicit
early-return check:
useEffect(() => {
if (typeof window === "undefined") return;
import("webfontloader").then(({ load }) => load(opts));
}, [opts]);The SSR build's typeof-tree-shaker rewrites typeof window to "undefined"
so the literal "undefined" === "undefined" collapses, esbuild DCEs everything
after the unconditional return, and the dynamic import (plus the entire
side-effect dep) is excluded from the SSR umd. At runtime in the browser
the guard is free.
Subpath exports
| Subpath | Surface |
| --- | --- |
| @homerunner-next/widget-core | Top-level barrel (re-exports types). Prefer subpaths for tree-shaking. |
| @homerunner-next/widget-core/manifest | PluginManifest type |
| @homerunner-next/widget-core/urls | resolvePluginAssetUrl, extractAssetFilename, getPluginAssetUrl (asset proxy moved to assets.homerunner.io/p/<slug>/<file>, single-hop sibling of /w/) |
| @homerunner-next/widget-core/globals | HRWidgetRuntime / HRPlugins types, getRuntime(), registerPlugin() |
| @homerunner-next/widget-core/plugin-schema | zodToManifestSchema, JSONSchema / PluginUiSchema types |
| @homerunner-next/widget-core/schema | zod widgetSchema + WidgetSchemaType (the base every widget extends) |
| @homerunner-next/widget-core/contracts | WidgetProps, LayoutWidgetProps, Feed, Widget<T>, PageSchema, SlotBinding |
| @homerunner-next/widget-core/runtime | mount, ClientWrapperForWidget, useFetchWidget, fetchFeed, fetchWidget, getFinalSettings, setupShadowDOM, usePortalContainer, useShadowHost, useTopLevelPortal, getSharedQueryClient |
| @homerunner-next/widget-core/utils | WidgetProvider, useWidget, useTranslation, useLocaleDetection, getProxiedImageUrl, getFeedQuery, getWidgetQuery, locale helpers |
| @homerunner-next/widget-core/vite | viteHomerunnerWidget({ slug }) build helper |
Every subpath ships ESM + CJS + .d.ts. Source is included for IDE go-to-definition.
Quick start (plugin author)
The fastest path is the scaffolder:
npx create-hr-plugin my-widgetThat generates a working plugin (vm.Script SSR + IIFE client) wired against this SDK. The bits below show what the scaffold does under the hood.
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import tailwindcss from "@tailwindcss/vite";
import { viteHomerunnerWidget } from "@homerunner-next/widget-core/vite";
export default defineConfig(({ command }) => {
const hr = viteHomerunnerWidget({ slug: "my-widget", command });
return {
plugins: [react(), tailwindcss(), ...hr.plugins],
define: hr.define,
build: hr.build,
server: { ...hr.server, port: 3001 },
};
});viteHomerunnerWidget wires the rollup externals (React / RD / RQ resolved against window.HRWidgetRuntime), dev-mode shim aliases, manifest generation with hashed filenames, and the /w/ plugin-asset proxy.
src/config.ts
import { z } from "zod";
import { widgetSchema } from "@homerunner-next/widget-core/schema";
import {
zodToManifestSchema,
type PluginUiSchema,
} from "@homerunner-next/widget-core/plugin-schema";
export const configZod = widgetSchema.extend({
title: z.string().default("My Widget"),
});
export const uiSchema: PluginUiSchema = {
title: { component: "TextInput", label: "Title" },
};
export const configSchema = zodToManifestSchema(configZod);
export type PluginConfig = z.infer<typeof configZod>;widgetSchema is the base every system widget extends — width, colorScheme, lightModeColors, darkModeColors, font, language, section, customCss, filter. The dashboard form renderer pairs these inherited fields with its built-in commonWidgetUiSchema, so your uiSchema only declares plugin-specific fields.
src/index.tsx
import { mount } from "@homerunner-next/widget-core/runtime";
import { registerPlugin } from "@homerunner-next/widget-core/globals";
import MyWidget from "./widget";
registerPlugin("my-widget", MyWidget);
function doMount() {
document.querySelectorAll<HTMLElement>("[data-hr-widget-container]").forEach((c) => {
if (c.querySelector(`[data-hr-widget="plugin:my-widget"]`)) {
mount(c, { widget: MyWidget, widgetType: "plugin:my-widget" });
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", doMount);
} else {
doMount();
}mount is bundled locally with the plugin so mount upgrades version with the plugin (instead of being pinned to whatever runtime the page loaded).
SSR module
import { fetchProperties } from "./data";
import type { PluginConfig } from "./config";
export async function dehydrateState(qc: any, ctx: { options: PluginConfig; feedId: number; widgetId: string }) {
// Prefetch data into the shared React Query client. Cache hydrates
// through the SSR HTML — no second fetch on the client.
}
export async function getStaticAssets(_options: PluginConfig) {
return {
css: ["dist/my-widget.css"],
js: ["dist/my-widget.iife.js"],
};
}The renderer fetches your built SSR bundle, evaluates it in a vm.Script sandbox with React / RD / RQ provided, and calls dehydrateState + getStaticAssets.
Plugin runtime contract
A plugin ships:
- Manifest JSON —
id,version,widgetType,ssr.url,assets.{js,css},runtime.react, plus aconfigSchema(JSON Schema, generated viazodToManifestSchema) and optionaluiSchema. Path-only asset URLs are resolved against the registered manifest origin by the renderer + dashboard asset proxy, so the same build works on any host. - SSR UMD bundle — exports
default(component),getInitialData,dehydrateState,getStaticAssets. Loaded server-side viavm.Script. - Client IIFE bundle — registers the component via
registerPlugin()and auto-mounts viamount()on every[data-hr-widget="plugin:<slug>"]it finds inside[data-hr-widget-container]. React / RD / RQ are externalized againstwindow.HRWidgetRuntime.
The host page loads the runtime IIFE once. Every plugin IIFE on the page externalizes against it, so React isn't bundled per-plugin.
Versioning
0.x — surface may change between minor versions. We aim for stability across patch versions.
License
MIT
