feat(custom-widgets): add extensible widget platform
This commit is contained in:
1 parent
6cca757493
commit
8f152f1929
317 files changed
+32326
-1788
No files matched your search
@@ -70,7 +70,7 @@ const AUTHORING_GUIDANCE = `You are writing one safe Homarr Custom JSX v2 dashbo
|
||||
Manifest contract:
|
||||
${leanShape}
|
||||
|
||||
Sources are keyed by name and must include "default". Auth is "none", "bearer", "basic", {"type":"apiKeyHeader","name":"X-Api-Key"}, or {"type":"apiKeyQuery","name":"api_key"}. Use the stable public API URL for public services and a clear suggested URL for self-hosted services; Homarr asks the installer for their own server URL. Never put credentials in the manifest.
|
||||
An API is optional: static widgets use empty sources and requests. Each request must reference a defined source; omitting source uses "default" only when that source exists. Auth is "none", "bearer", "basic", {"type":"apiKeyHeader","name":"X-Api-Key"}, or {"type":"apiKeyQuery","name":"api_key"}. Use stable public API URLs or clear suggested self-hosted URLs; Homarr collects local setup and credentials separately.
|
||||
|
||||
Requests are keyed by ID. Defaults are source "default", kind "query", method "GET", query trigger "load", inherited auth, and permission "view" for queries or "modify" for actions. Actions are always manual. DELETE is valid only for actions, requires full permission, and receives confirmation automatically. Use {option:name} or {"$option":"name"} for saved options. Use {param:name} or {"$param":"name"} only for invocation-time params supplied by SubFetch, ActionButton, or ToggleSwitch. Load queries cannot use params. Values and primitive types are inferred from references; do not declare parameters or option bindings. Paths and query values must be primitive; JSON bodies may bind structured options.
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ interface HomarrCustomWidgetV2 {
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
The object key \`default\` is the required source ID; \`default\` is not a property on a source. Source properties are \`name?\`, \`baseUrl\`, \`networkScope\`, and \`auth?\`:
|
||||
Static widgets use empty \`sources\` and \`requests\`. Each request must resolve to a defined source; omitting \`source\` uses \`default\` only when that ID exists. Source properties are \`name?\`, \`baseUrl\`, \`networkScope\`, and \`auth?\`:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
@@ -133,6 +133,8 @@ Use expression callbacks for supported collections and trusted slots. No callbac
|
||||
`,
|
||||
"references/security.md": `# Security
|
||||
|
||||
Private access includes LAN, Docker DNS, and Tailscale CGNAT addresses. Loopback means the Homarr server or container, not the viewer's browser.
|
||||
|
||||
All requests use Homarr's protected server executor. Source origin, network scope, DNS, redirects, SSRF, rate limits, permissions, size limits, timeouts, and encrypted credential injection remain enforced.
|
||||
|
||||
The JSX interpreter blocks imports, hooks, refs, raw event callbacks, browser requests, eval, arbitrary functions, prototype access, unsafe URLs, global CSS escape, arbitrary portals, bigint, statement blocks, IIFEs, and recursion. Regex literals must be bounded and reject backreferences, lookbehind, nested quantifiers, excessive length, and unsupported flags.
|
||||
@@ -143,21 +145,21 @@ Credentials are stored separately and never exported or returned to an agent. A
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_MD = `---
|
||||
name: homarr-custom-widget
|
||||
description: Author, validate, preview, test, install, or configure API-backed Homarr Custom JSX v2 widgets.
|
||||
description: Author, validate, preview, test, install, or configure static or API-backed Homarr Custom JSX v2 widgets.
|
||||
---
|
||||
|
||||
# Homarr Custom Widget
|
||||
|
||||
Author one widget or a coordinated set of widgets. Load only needed release-matched context. Run lifecycle tools alone; independent reads may run together. For a set, research once and finish each widget's validation, evidence, and persistence before the next.
|
||||
Author one widget or a coordinated set. Load release-matched context on demand. Run lifecycle tools alone; independent reads may run together. Finish each widget's validation, evidence, and persistence before the next.
|
||||
|
||||
1. Read primary API documentation. Use web search when documentation is not supplied or may have changed.
|
||||
2. Create credential-free definitions with keyed \`sources\`, \`requests\`, optional \`options\`, and safe JSX \`template\`.
|
||||
1. For connected widgets, read primary API documentation; use web search when it is missing or may have changed.
|
||||
2. Create credential-free definitions with keyed \`sources\`, \`requests\`, optional \`options\`, and safe JSX \`template\`. Static widgets use empty \`sources\` and \`requests\`; an API is optional.
|
||||
3. While drafting, use \`customWidget_validateTemplate\` for focused JSX diagnostics without resending the manifest.
|
||||
4. Send the definition once to \`customWidget_previewCreate\` and test its queries/actions. For a JSX-only fix, validate, call \`customWidget_previewReviseTemplate\` with its session, and retest; it inherits the manifest and resets evidence. Create a preview only for source/request/option changes.
|
||||
5. Configure deployment-specific source URLs and credentials through Homarr; never repeat plaintext.
|
||||
6. Persist each exact final tested preview with \`customWidget_createFromPreview\`. Do not resend a large definition through \`customWidget_create\` when a preview session is available.
|
||||
|
||||
Treat a supplied sample or successful preview response as the binding contract. Render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Humanize numeric enums with indexed literal label arrays, omit absent numeric values instead of inventing zero, and label timestamp timezones. Give recoverable load errors and empty states a clear refresh or retry path.
|
||||
Use supplied samples or preview responses as the data contract. Render requested fields and all returned items; guard optional nested values. Humanize numeric enums with label arrays, omit absent numbers, and label timestamp timezones. Give load errors and empty states a refresh or retry path.
|
||||
|
||||
Use \`{option:name}\` or \`$option\` for saved options. Use \`{param:name}\` or \`$param\` for values supplied by \`SubFetch\`, \`ActionButton\`, or \`ToggleSwitch\`. Load queries cannot use invocation parameters. Render load queries from \`data\` and \`status\` with \`RefreshButton\`; reserve \`SubFetch\` for manual parameterized queries. Templates read \`data\`, \`status\`, \`options\`, and temporary \`inputs\`.
|
||||
|
||||
@@ -169,7 +171,7 @@ Plan capabilities. Use one \`customWidget_findComponents\` search per job. Batch
|
||||
|
||||
Do not simplify a useful workflow merely because the template is interpreted. Freely compose any supported installed components with multiple sources, requests, options, \`choicesFrom\`, bound filters, charts, responsive detail areas, manual queries, and safe actions when they improve the user's job. Complexity must remain purposeful rather than decorative.
|
||||
|
||||
Polish with a divided list or responsive media grid. Make one metric/action asymmetrically primary; keep headers compact, identity/state clear, metadata quiet, and one badge. At base artwork fills its row and caps above xs; never combine full-width media and nowrap.
|
||||
Use divided lists or responsive media grids, one primary metric/action, compact headers, quiet metadata, and one badge. Full-width artwork wraps at base and caps above xs.
|
||||
|
||||
Make initial states actionable and wrap variable labels/values on narrow tiles. Do not use an unlabeled decorative icon as an empty state.
|
||||
|
||||
@@ -188,7 +190,7 @@ Repository installations expose these as files under \`references/\`. MCP client
|
||||
|
||||
const CUSTOM_WIDGET_SKILL_ENTRYPOINT_MD = `# Homarr Custom Widget authoring index
|
||||
|
||||
Use release-matched tools and load only context required by the design. Research primary API documentation once. For each widget, validate JSX independently with \`customWidget_validateTemplate\`, create one coherent preview, test every returned query and simulated action, then persist that exact preview. For a response-driven JSX-only correction, validate it and call \`customWidget_previewReviseTemplate\`; it inherits the manifest, resets evidence, and avoids resending sources, requests, or options. Complete one widget before drafting the next in a coordinated set.
|
||||
Use release-matched tools and load only context required by the design. Research API documentation only for connected widgets. For each widget, validate JSX independently with \`customWidget_validateTemplate\`, create one coherent preview, test every returned query and simulated action, then persist that exact preview. For a response-driven JSX-only correction, validate it and call \`customWidget_previewReviseTemplate\`; it inherits the manifest, resets evidence, and avoids resending sources, requests, or options. Complete one widget before drafting the next in a coordinated set.
|
||||
|
||||
Before drafting, map every requested capability to a rendered field, bound control, or safe action. Give each widget a purpose-specific visual signature: asymmetric priority metric/action, quieter supporting metrics, compact header, accurate status language, resolved and accessible imagery, responsive details, and actionable initial, loading, empty, error, and success states. Avoid dead controls, raw relative artwork paths, and repetitive nested cards.
|
||||
|
||||
|
||||
@@ -22,6 +22,21 @@ export const CUSTOM_WIDGET_STARTER: HomarrCustomWidgetV2Input = {
|
||||
template: CUSTOM_JSX_STARTER,
|
||||
};
|
||||
|
||||
export const CUSTOM_STATIC_JSX_STARTER = `<Stack gap="sm" p="sm">
|
||||
<Text fw={700}>{options.title}</Text>
|
||||
<Text size="sm">Make this space your own. Add a source when you need live data.</Text>
|
||||
</Stack>`;
|
||||
|
||||
export const CUSTOM_WIDGET_STATIC_STARTER: HomarrCustomWidgetV2Input = {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "New custom widget",
|
||||
description: "",
|
||||
sources: {},
|
||||
requests: {},
|
||||
options: { title: { label: "Title", control: "text", default: "My widget" } },
|
||||
template: CUSTOM_STATIC_JSX_STARTER,
|
||||
};
|
||||
|
||||
export const customJsxExamples: readonly CustomJsxExample[] = [
|
||||
{
|
||||
id: "service-dashboard",
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./assistant-authoring-phase";
|
||||
export * from "./assistant-tool-input";
|
||||
export * from "./assistant-tool-step";
|
||||
export * from "./examples";
|
||||
export { redactCustomWidgetCredentialLiterals } from "./definition-security";
|
||||
export * from "./import";
|
||||
export * from "./options";
|
||||
export * from "./schema";
|
||||
|
||||
@@ -58,8 +58,7 @@ export type CustomWidgetSource = z.infer<typeof customWidgetSourceSchema>;
|
||||
|
||||
export const customWidgetSourcesSchema = z
|
||||
.record(customWidgetIdentifierSchema, customWidgetSourceSchema)
|
||||
.refine((sources) => Object.keys(sources).length <= 8, "A widget can use at most 8 API sources")
|
||||
.refine((sources) => Object.hasOwn(sources, "default"), "A widget must define a default API source");
|
||||
.refine((sources) => Object.keys(sources).length <= 8, "A widget can use at most 8 API sources");
|
||||
|
||||
export const optionReferenceSchema = z.strictObject({ $option: customWidgetIdentifierSchema });
|
||||
export const parameterReferenceSchema = z.strictObject({ $param: customWidgetIdentifierSchema });
|
||||
|
||||
@@ -48,6 +48,15 @@ export function hasSameCustomWidgetSourceAuthentication(left: CustomWidgetSource
|
||||
return left.auth.type === right.auth.type && left.auth.name === right.auth.name;
|
||||
}
|
||||
|
||||
/** Credentials belong to an origin, network scope, and authentication destination; URL paths may change. */
|
||||
export function hasSameCustomWidgetSourceBinding(left: CustomWidgetSource, right: CustomWidgetSource) {
|
||||
return (
|
||||
new URL(left.baseUrl).origin === new URL(right.baseUrl).origin &&
|
||||
left.networkScope === right.networkScope &&
|
||||
hasSameCustomWidgetSourceAuthentication(left, right)
|
||||
);
|
||||
}
|
||||
|
||||
export function getCustomWidgetSourceSetups(
|
||||
sources: Record<string, CustomWidgetSource>,
|
||||
configuredSecrets: readonly CustomWidgetConfiguredSecret[] = [],
|
||||
|
||||
@@ -16,6 +16,7 @@ export const ROOT_BINDINGS = new Set([
|
||||
"data",
|
||||
"options",
|
||||
"inputs",
|
||||
"host",
|
||||
"status",
|
||||
"decodeURIComponent",
|
||||
"encodeURIComponent",
|
||||
@@ -26,6 +27,7 @@ export const ROOT_BINDINGS = new Set([
|
||||
"undefined",
|
||||
]);
|
||||
|
||||
// Existing v2 callbacks may use `host`; adding host context must preserve those templates.
|
||||
export const RESERVED_LOCAL_BINDINGS = new Set(["data", "status", "options", "inputs"]);
|
||||
const callableRootBindings = new Set([
|
||||
"Boolean",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { customWidgetArtifactSchema } from "./artifact";
|
||||
import { customWidgetPackageSchema } from "./schema";
|
||||
|
||||
export const customWidgetArchiveSchema = z.strictObject({
|
||||
format: z.literal("homarr-widget-archive-v3"),
|
||||
source: customWidgetPackageSchema,
|
||||
artifact: customWidgetArtifactSchema,
|
||||
});
|
||||
export type CustomWidgetArchive = z.infer<typeof customWidgetArchiveSchema>;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { widgetPackageManifestSchema } from "./schema";
|
||||
|
||||
export const compiledWidgetViewSchema = z.strictObject({
|
||||
javascript: z.string(),
|
||||
css: z.string(),
|
||||
hostModules: z.array(z.string()).optional(),
|
||||
});
|
||||
export const customWidgetArtifactSchema = z.strictObject({
|
||||
format: z.literal("homarr-widget-artifact-v3"),
|
||||
digest: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
sourceDigest: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
manifest: widgetPackageManifestSchema,
|
||||
client: z.strictObject({
|
||||
tile: compiledWidgetViewSchema,
|
||||
advanced: compiledWidgetViewSchema.optional(),
|
||||
configuration: compiledWidgetViewSchema.optional(),
|
||||
}),
|
||||
server: z.string().optional(),
|
||||
dependencyLock: z.strictObject({
|
||||
dependencies: z.record(z.string(), z.string()),
|
||||
compilerVersion: z.string(),
|
||||
nodeVersion: z.string(),
|
||||
lockfile: z.string().optional(),
|
||||
bundledHostDependencies: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type CompiledWidgetView = z.infer<typeof compiledWidgetViewSchema>;
|
||||
export type CustomWidgetArtifact = z.infer<typeof customWidgetArtifactSchema>;
|
||||
@@ -0,0 +1,92 @@
|
||||
// JavaScript source avoids capturing esbuild/SWC helpers from transpiled function serialization.
|
||||
const entrypoint = String.raw`
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const running = new Map();
|
||||
const pending = new Map();
|
||||
const loaded = require(process.argv[2]);
|
||||
const handlers = loaded.default ?? loaded;
|
||||
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
||||
function send(message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!process.send || !process.connected) return reject(new Error("Widget host disconnected"));
|
||||
try {
|
||||
if (Buffer.byteLength(JSON.stringify(message), "utf8") > 8 * 1024 * 1024) {
|
||||
return reject(new Error("Widget IPC message exceeds 8 MiB; stream smaller chunks"));
|
||||
}
|
||||
process.send(message, (error) => { if (error) reject(error); else resolve(); });
|
||||
} catch (error) { reject(new Error(errorMessage(error))); }
|
||||
});
|
||||
}
|
||||
function sdk(invocationId, operation, input) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = randomUUID();
|
||||
pending.set(id, { invocationId, resolve, reject });
|
||||
void send({ type: "sdk", id, invocationId, operation, input }).catch((error) => {
|
||||
pending.delete(id);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
async function invoke(message) {
|
||||
const controller = new AbortController();
|
||||
running.set(message.id, controller);
|
||||
try {
|
||||
const handler = handlers[message.handler];
|
||||
if (typeof handler !== "function") throw new Error("Handler '" + message.handler + "' is not exported");
|
||||
const result = await handler(message.input, {
|
||||
...message.context, signal: controller.signal,
|
||||
sdk: { invoke: (operation, input) => sdk(message.id, operation, input) },
|
||||
});
|
||||
if (message.kind === "subscription") {
|
||||
if (!result || typeof result !== "object" || !(Symbol.asyncIterator in result)) {
|
||||
throw new Error("Subscription handlers must return an AsyncIterable");
|
||||
}
|
||||
for await (const value of result) {
|
||||
if (controller.signal.aborted) break;
|
||||
await send({ type: "event", id: message.id, value });
|
||||
}
|
||||
await send({ type: "result", id: message.id });
|
||||
} else await send({ type: "result", id: message.id, value: result });
|
||||
} catch (error) {
|
||||
await send({ type: "result", id: message.id, error: errorMessage(error), errorCode: typeof error?.code === "string" ? error.code : undefined }).catch(() => undefined);
|
||||
} finally {
|
||||
controller.abort();
|
||||
running.delete(message.id);
|
||||
for (const [id, request] of pending) {
|
||||
if (request.invocationId !== message.id) continue;
|
||||
pending.delete(id);
|
||||
request.reject(new Error("Widget invocation ended"));
|
||||
}
|
||||
}
|
||||
}
|
||||
process.on("message", (message) => {
|
||||
if (message.type === "invoke") void invoke(message);
|
||||
if (message.type === "cancel") {
|
||||
running.get(message.id)?.abort();
|
||||
for (const [id, request] of pending) {
|
||||
if (request.invocationId !== message.id) continue;
|
||||
pending.delete(id);
|
||||
request.reject(new Error("Widget invocation cancelled"));
|
||||
}
|
||||
}
|
||||
if (message.type === "sdk-result") {
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
pending.delete(message.id);
|
||||
if (message.error) request.reject(Object.assign(new Error(message.error), { code: message.errorCode }));
|
||||
else request.resolve(message.value);
|
||||
}
|
||||
if (message.type === "shutdown") {
|
||||
for (const controller of running.values()) controller.abort();
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
process.on("disconnect", () => process.exit(0));
|
||||
const heartbeat = setInterval(() => void send({ type: "heartbeat" }).catch(() => process.exit(1)), 2_000);
|
||||
heartbeat.unref();
|
||||
void send({ type: "ready", handlers: Object.keys(handlers).filter((name) => typeof handlers[name] === "function") });
|
||||
`;
|
||||
|
||||
export function getWidgetChildEntrypoint(): string {
|
||||
return entrypoint;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { stat, readFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
customWidgetCollectionSchema,
|
||||
widgetCollectionContentsSchema,
|
||||
WIDGET_COLLECTION_MAX_BYTES,
|
||||
} from "./collection";
|
||||
import type { CustomWidgetCollection, WidgetCollectionContents } from "./collection";
|
||||
import { assertWidgetArtifactIntegrity, hashWidgetArtifactContent } from "./integrity";
|
||||
|
||||
export function validateWidgetCollectionArchive(input: unknown): CustomWidgetCollection {
|
||||
if (Buffer.byteLength(JSON.stringify(input)) > WIDGET_COLLECTION_MAX_BYTES)
|
||||
throw new Error("Widget collection exceeds the 128 MiB limit");
|
||||
const collection = customWidgetCollectionSchema.parse(input);
|
||||
const { digest, ...contents } = collection;
|
||||
if (hashWidgetArtifactContent(contents) !== digest)
|
||||
throw new Error("Widget collection checksum does not match its contents");
|
||||
for (const entry of collection.widgets) {
|
||||
try {
|
||||
assertWidgetArtifactIntegrity(entry.archive.artifact, entry.archive.source);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid artifact";
|
||||
throw new Error(`Widget '${entry.key}': ${message}`, { cause: error });
|
||||
}
|
||||
}
|
||||
return collection;
|
||||
}
|
||||
|
||||
export function createWidgetCollectionArchive(input: WidgetCollectionContents): CustomWidgetCollection {
|
||||
const contents = widgetCollectionContentsSchema.parse(input);
|
||||
return validateWidgetCollectionArchive({ ...contents, digest: hashWidgetArtifactContent(contents) });
|
||||
}
|
||||
|
||||
export async function loadWidgetCollectionArchive(path: string): Promise<CustomWidgetCollection> {
|
||||
if ((await stat(path)).size > WIDGET_COLLECTION_MAX_BYTES)
|
||||
throw new Error("Widget collection exceeds the 128 MiB limit");
|
||||
return validateWidgetCollectionArchive(JSON.parse(await readFile(path, "utf8")));
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getCustomWidgetDefaultOptions, validateCustomWidgetOptions } from "../core/options";
|
||||
import { customWidgetArchiveSchema } from "./archive";
|
||||
import { isWidgetConnectionCompatible, widgetPackageConnectionSchema, widgetPackageManifestSchema } from "./schema";
|
||||
|
||||
export const WIDGET_COLLECTION_MAX_BYTES = 128 * 1024 * 1024;
|
||||
export const widgetCollectionKeySchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z][A-Za-z0-9._-]*$/u)
|
||||
.refine((key) => !["__proto__", "prototype", "constructor"].includes(key), "Reserved collection key");
|
||||
|
||||
export const widgetCollectionManifestSchema = widgetPackageManifestSchema.pick({
|
||||
id: true,
|
||||
version: true,
|
||||
name: true,
|
||||
description: true,
|
||||
author: true,
|
||||
license: true,
|
||||
});
|
||||
|
||||
const digest = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const publicUrl = z
|
||||
.string()
|
||||
.url()
|
||||
.refine((value) => {
|
||||
const url = new URL(value);
|
||||
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash;
|
||||
}, "Attribution URLs must omit credentials, query parameters and fragments");
|
||||
|
||||
export const widgetPortableWorkshopOriginSchema = z.strictObject({
|
||||
kind: z.literal("workshop"),
|
||||
workshopApiUrl: publicUrl,
|
||||
submissionId: z.string().max(128),
|
||||
releaseId: z.string().max(128),
|
||||
version: z.string().max(128),
|
||||
sourceDigest: digest,
|
||||
artifactDigest: digest,
|
||||
author: z.string().max(256),
|
||||
forkedFrom: z.string().max(128),
|
||||
});
|
||||
export const widgetPortableOriginSchema = z.union([
|
||||
widgetPortableWorkshopOriginSchema,
|
||||
z.strictObject({
|
||||
kind: z.literal("fork"),
|
||||
packageId: z.string().max(128),
|
||||
artifactDigest: digest.optional(),
|
||||
upstream: widgetPortableWorkshopOriginSchema.optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const widgetCollectionConnectionsSchema = z
|
||||
.record(widgetCollectionKeySchema, widgetPackageConnectionSchema)
|
||||
.refine((value) => Object.keys(value).length <= 128, "Collections support at most 128 connection slots");
|
||||
|
||||
export const widgetCollectionEntrySchema = z.strictObject({
|
||||
key: widgetCollectionKeySchema,
|
||||
name: z.string().trim().min(1).max(128).optional(),
|
||||
archive: customWidgetArchiveSchema,
|
||||
connections: z.record(widgetCollectionKeySchema, widgetCollectionKeySchema).default({}),
|
||||
options: z.record(widgetCollectionKeySchema, z.json()).default({}),
|
||||
origin: widgetPortableOriginSchema.optional(),
|
||||
});
|
||||
|
||||
export const widgetCollectionContentsSchema = z
|
||||
.strictObject({
|
||||
format: z.literal("homarr-widget-collection-v1"),
|
||||
manifest: widgetCollectionManifestSchema,
|
||||
connections: widgetCollectionConnectionsSchema.default({}),
|
||||
widgets: z.array(widgetCollectionEntrySchema).min(1).max(32),
|
||||
})
|
||||
.superRefine((collection, ctx) => {
|
||||
const keys = new Set<string>();
|
||||
collection.widgets.forEach((entry, index) => {
|
||||
const issue = (path: string[], message: string) =>
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["widgets", index, ...path],
|
||||
message: `Widget '${entry.key}': ${message}`,
|
||||
});
|
||||
if (keys.has(entry.key)) issue(["key"], "entry keys must be unique");
|
||||
keys.add(entry.key);
|
||||
const requirements = entry.archive.source.connections;
|
||||
for (const [name, slot] of Object.entries(entry.connections)) {
|
||||
const requirement = requirements[name];
|
||||
const shared = collection.connections[slot];
|
||||
if (!requirement) issue(["connections", name], "unknown package connection");
|
||||
else if (!shared) issue(["connections", name], `collection slot '${slot}' is undeclared`);
|
||||
else if (!isWidgetConnectionCompatible(requirement, shared))
|
||||
issue(["connections", name], `slot '${slot}' requires compatible ${requirement.kind} settings`);
|
||||
else if (!requirement.optional && shared.optional)
|
||||
issue(["connections", name], `slot '${slot}' must be required because this widget requires it`);
|
||||
}
|
||||
for (const [name, requirement] of Object.entries(requirements)) {
|
||||
if (!requirement.optional && !entry.connections[name])
|
||||
issue(["connections", name], "required connection needs a collection slot");
|
||||
}
|
||||
const values = { ...getCustomWidgetDefaultOptions(entry.archive.source.options), ...entry.options };
|
||||
for (const invalid of validateCustomWidgetOptions(entry.archive.source.options, values))
|
||||
issue(["options", invalid.path.replace(/^configuration\./u, "")], invalid.message);
|
||||
});
|
||||
});
|
||||
|
||||
export const customWidgetCollectionSchema = widgetCollectionContentsSchema.safeExtend({ digest });
|
||||
export type CustomWidgetCollection = z.infer<typeof customWidgetCollectionSchema>;
|
||||
export type WidgetCollectionContents = z.input<typeof widgetCollectionContentsSchema>;
|
||||
export type WidgetCollectionManifest = z.infer<typeof widgetCollectionManifestSchema>;
|
||||
export type WidgetPortableOrigin = z.infer<typeof widgetPortableOriginSchema>;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Bundle locations differ between workspace TS, Next server chunks, and /app/apps/cli/cli.cjs. */
|
||||
function runtimeModulePaths() {
|
||||
const modulePath = typeof __filename === "string" ? __filename : fileURLToPath(import.meta.url);
|
||||
const paths = [resolve(modulePath), join(process.cwd(), "package.json")];
|
||||
if (process.argv[1]) paths.push(resolve(process.argv[1]));
|
||||
return [...new Set(paths)];
|
||||
}
|
||||
|
||||
export function resolveWidgetCompilerDependency(specifier: string) {
|
||||
for (const path of runtimeModulePaths()) {
|
||||
try {
|
||||
return createRequire(path).resolve(specifier);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "MODULE_NOT_FOUND")) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`The installed widget compiler is missing '${specifier}'. Include its production dependency files.`);
|
||||
}
|
||||
|
||||
export async function resolveWidgetServerSdk() {
|
||||
try {
|
||||
return resolveWidgetCompilerDependency("@homarr/widget-sdk/server");
|
||||
} catch {
|
||||
// Standalone traces retain package source, but workspace node_modules links are not guaranteed.
|
||||
}
|
||||
const inspected = new Set<string>();
|
||||
for (const modulePath of runtimeModulePaths()) {
|
||||
let directory = dirname(modulePath);
|
||||
while (!inspected.has(directory)) {
|
||||
inspected.add(directory);
|
||||
const packageDirectory = join(directory, "packages", "widget-sdk");
|
||||
try {
|
||||
const manifest = JSON.parse(await readFile(join(packageDirectory, "package.json"), "utf8")) as {
|
||||
name?: string;
|
||||
};
|
||||
const source = join(packageDirectory, "src", "server.ts");
|
||||
if (manifest.name === "@homarr/widget-sdk" && (await stat(source)).isFile()) return source;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
||||
}
|
||||
const parent = dirname(directory);
|
||||
if (parent === directory) break;
|
||||
directory = parent;
|
||||
}
|
||||
}
|
||||
throw new Error("The installed widget compiler is missing packages/widget-sdk/src/server.ts from its output trace");
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { isBuiltin } from "node:module";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, extname, join, posix } from "node:path";
|
||||
import type { Loader, Plugin } from "esbuild";
|
||||
|
||||
import { HOST_WIDGET_MODULES } from "./schema";
|
||||
import { resolveWidgetCompilerDependency, resolveWidgetServerSdk } from "./compiler-dependencies";
|
||||
import type { CustomWidgetPackage } from "./schema";
|
||||
|
||||
const loaders: Record<string, Loader> = {
|
||||
".ts": "ts",
|
||||
".tsx": "tsx",
|
||||
".js": "js",
|
||||
".jsx": "jsx",
|
||||
".mjs": "js",
|
||||
".cjs": "js",
|
||||
".json": "json",
|
||||
".css": "css",
|
||||
".svg": "dataurl",
|
||||
".png": "dataurl",
|
||||
".jpg": "dataurl",
|
||||
".jpeg": "dataurl",
|
||||
".webp": "dataurl",
|
||||
".gif": "dataurl",
|
||||
".woff": "dataurl",
|
||||
".woff2": "dataurl",
|
||||
".txt": "text",
|
||||
".md": "text",
|
||||
};
|
||||
|
||||
export function widgetPackageFilesPlugin(
|
||||
widget: CustomWidgetPackage,
|
||||
server: boolean,
|
||||
dependencyDirectory?: string,
|
||||
bundledHostDependencies: Record<string, string> = {},
|
||||
): Plugin {
|
||||
return {
|
||||
name: "homarr-widget-package",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /\.node$/, namespace: "file" }, () => ({
|
||||
errors: [
|
||||
{
|
||||
text: "Native Node addons require a compatible external runner; portable widget artifacts support bundled JavaScript dependencies",
|
||||
},
|
||||
],
|
||||
}));
|
||||
build.onResolve({ filter: /.*/ }, async (args) => {
|
||||
if (
|
||||
args.path === "@tabler/icons-react" &&
|
||||
!widget.dependencies[args.path] &&
|
||||
args.namespace !== "file" &&
|
||||
!args.pluginData?.widgetImplicit
|
||||
) {
|
||||
const manifestPath = resolveWidgetCompilerDependency("@tabler/icons-react/package.json");
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { version: string };
|
||||
bundledHostDependencies[args.path] = manifest.version;
|
||||
return build.resolve(args.path, {
|
||||
resolveDir: dirname(manifestPath),
|
||||
kind: args.kind,
|
||||
pluginData: { widgetImplicit: true },
|
||||
});
|
||||
}
|
||||
if (args.pluginData?.widgetImplicit) return undefined;
|
||||
if (!server && HOST_WIDGET_MODULES.includes(args.path as (typeof HOST_WIDGET_MODULES)[number])) {
|
||||
return { path: args.path, external: true };
|
||||
}
|
||||
if (server && args.path === "@homarr/widget-sdk/server") {
|
||||
return {
|
||||
path: await resolveWidgetServerSdk(),
|
||||
namespace: "file",
|
||||
};
|
||||
}
|
||||
if (args.path.startsWith("@homarr/widget-sdk")) {
|
||||
return {
|
||||
errors: [
|
||||
{ text: `SDK import '${args.path}' is unavailable in this ${server ? "server" : "browser"} module` },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (isBuiltin(args.path)) {
|
||||
if (server) return { path: args.path, external: true };
|
||||
return { errors: [{ text: `Node module '${args.path}' cannot be imported by a widget view` }] };
|
||||
}
|
||||
if (args.namespace === "file" || args.pluginData?.widgetDependency) return undefined;
|
||||
if (args.kind === "entry-point" || args.path.startsWith(".")) {
|
||||
let path = args.path;
|
||||
if (args.kind !== "entry-point") path = posix.normalize(posix.join(posix.dirname(args.importer), path));
|
||||
const resolved = resolveWidgetFile(widget.files, path);
|
||||
if (!resolved) return { errors: [{ text: `Package file '${path}' was not found` }] };
|
||||
return { path: resolved, namespace: "widget-file" };
|
||||
}
|
||||
if (!dependencyDirectory)
|
||||
return { errors: [{ text: `Install the declared dependency '${args.path}' before compiling` }] };
|
||||
const packageName = getDependencyName(args.path);
|
||||
const expectedVersion = widget.dependencies[packageName];
|
||||
if (!expectedVersion) return { errors: [{ text: `Declare an exact version for dependency '${packageName}'` }] };
|
||||
const resolved = await build.resolve(args.path, {
|
||||
resolveDir: dependencyDirectory,
|
||||
kind: args.kind,
|
||||
pluginData: { widgetDependency: true },
|
||||
});
|
||||
if (resolved.errors.length > 0) return { errors: resolved.errors };
|
||||
await assertDependencyVersion(resolved.path, packageName, expectedVersion);
|
||||
return { path: resolved.path, namespace: "file" };
|
||||
});
|
||||
build.onLoad({ filter: /.*/, namespace: "widget-file" }, (args) => {
|
||||
let loader = loaders[extname(args.path)];
|
||||
if (args.path.endsWith(".module.css")) loader = "local-css";
|
||||
if (!loader) return { errors: [{ text: `Unsupported widget file type: '${args.path}'` }] };
|
||||
const source = widget.files[args.path] ?? "";
|
||||
let contents: string | Uint8Array = source;
|
||||
if (loader === "dataurl" && /^data:[^,]+;base64,/u.test(source)) {
|
||||
contents = Buffer.from(source.slice(source.indexOf(",") + 1), "base64");
|
||||
}
|
||||
return { contents, loader };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWidgetFile(files: Record<string, string>, path: string) {
|
||||
const candidates = [
|
||||
path,
|
||||
...[".ts", ".tsx", ".js", ".jsx", ".json", "/index.ts", "/index.tsx", "/index.js"].map(
|
||||
(extension) => `${path}${extension}`,
|
||||
),
|
||||
];
|
||||
return candidates.find((candidate) => Object.hasOwn(files, candidate));
|
||||
}
|
||||
|
||||
function getDependencyName(specifier: string) {
|
||||
if (specifier.startsWith("@")) return specifier.split("/").slice(0, 2).join("/");
|
||||
return specifier.split("/")[0] ?? specifier;
|
||||
}
|
||||
|
||||
async function assertDependencyVersion(resolved: string, name: string, expected: string) {
|
||||
let directory = dirname(resolved);
|
||||
while (true) {
|
||||
try {
|
||||
const manifest = JSON.parse(await readFile(join(directory, "package.json"), "utf8")) as {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
if (manifest.name === name) {
|
||||
if (manifest.version !== expected)
|
||||
throw new Error(`Dependency '${name}' requires ${expected}; installed ${manifest.version}`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
||||
}
|
||||
const parent = dirname(directory);
|
||||
if (parent === directory) throw new Error(`Cannot verify installed dependency '${name}'`);
|
||||
directory = parent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { isBuiltin } from "node:module";
|
||||
import { build, version } from "esbuild";
|
||||
|
||||
import type { CompiledWidgetView, CustomWidgetArtifact } from "./artifact";
|
||||
import { customWidgetArtifactSchema } from "./artifact";
|
||||
import { widgetPackageFilesPlugin } from "./compiler-files";
|
||||
import { prepareCustomWidgetDependencies } from "./dependencies";
|
||||
import { hashWidgetArtifactContent } from "./integrity";
|
||||
import { customWidgetPackageSchema } from "./schema";
|
||||
import type { CustomWidgetPackage } from "./schema";
|
||||
|
||||
export interface WidgetPackageCompileOptions {
|
||||
dependencyDirectory?: string;
|
||||
rootDirectory?: string;
|
||||
allowDependencyInstall?: boolean;
|
||||
}
|
||||
|
||||
export async function compileCustomWidgetPackage(
|
||||
input: unknown,
|
||||
options: WidgetPackageCompileOptions = {},
|
||||
): Promise<CustomWidgetArtifact> {
|
||||
const widget = customWidgetPackageSchema.parse(input);
|
||||
const sourceDigest = hashWidgetArtifactContent(widget);
|
||||
if (Object.keys(widget.dependencies).length > 0 && !options.dependencyDirectory && options.rootDirectory) {
|
||||
options = {
|
||||
...options,
|
||||
dependencyDirectory: await prepareCustomWidgetDependencies(widget, sourceDigest, {
|
||||
rootDirectory: options.rootDirectory,
|
||||
allowDependencyInstall: options.allowDependencyInstall,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const compilation = { ...options, bundledHostDependencies: {} as Record<string, string> };
|
||||
const tile = await compileEntry(widget, widget.manifest.entrypoints.tile, false, compilation);
|
||||
const client: CustomWidgetArtifact["client"] = { tile };
|
||||
for (const surface of ["advanced", "configuration"] as const) {
|
||||
const entrypoint = widget.manifest.entrypoints[surface];
|
||||
if (entrypoint) client[surface] = await compileEntry(widget, entrypoint, false, compilation);
|
||||
}
|
||||
let server: string | undefined;
|
||||
if (widget.manifest.entrypoints.server) {
|
||||
server = (await compileEntry(widget, widget.manifest.entrypoints.server, true, compilation)).javascript;
|
||||
}
|
||||
const artifact = {
|
||||
format: "homarr-widget-artifact-v3" as const,
|
||||
sourceDigest,
|
||||
manifest: widget.manifest,
|
||||
client,
|
||||
server,
|
||||
dependencyLock: {
|
||||
dependencies: widget.dependencies,
|
||||
compilerVersion: version,
|
||||
nodeVersion: process.versions.node,
|
||||
lockfile: await readDependencyLock(options.dependencyDirectory),
|
||||
bundledHostDependencies: compilation.bundledHostDependencies,
|
||||
},
|
||||
};
|
||||
return customWidgetArtifactSchema.parse({ ...artifact, digest: hashWidgetArtifactContent(artifact) });
|
||||
}
|
||||
|
||||
async function compileEntry(
|
||||
widget: CustomWidgetPackage,
|
||||
entrypoint: string,
|
||||
server: boolean,
|
||||
options: WidgetPackageCompileOptions & { bundledHostDependencies: Record<string, string> },
|
||||
): Promise<CompiledWidgetView> {
|
||||
const result = await build({
|
||||
entryPoints: [entrypoint],
|
||||
bundle: true,
|
||||
write: false,
|
||||
metafile: true,
|
||||
outfile: "widget.cjs",
|
||||
format: "cjs",
|
||||
platform: server ? "node" : "browser",
|
||||
target: server ? "node24" : "es2022",
|
||||
jsx: "automatic",
|
||||
sourcemap: "inline",
|
||||
logLevel: "silent",
|
||||
plugins: [widgetPackageFilesPlugin(widget, server, options.dependencyDirectory, options.bundledHostDependencies)],
|
||||
define: server ? {} : { "process.env.NODE_ENV": '"production"' },
|
||||
});
|
||||
if (server && result.metafile) {
|
||||
for (const output of Object.values(result.metafile.outputs)) {
|
||||
for (const imported of output.imports) {
|
||||
if (imported.external && !isBuiltin(imported.path)) {
|
||||
throw new Error(
|
||||
`Server dependency '${imported.path}' was not bundled; runtime dependency installation is unsupported`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const javascript = result.outputFiles?.find((file) => file.path.endsWith(".cjs"))?.text;
|
||||
if (!javascript) throw new Error(`The compiler produced no JavaScript for '${entrypoint}'`);
|
||||
const hostModules = [
|
||||
...new Set(
|
||||
Object.values(result.metafile?.outputs ?? {})
|
||||
.flatMap((output) => output.imports)
|
||||
.filter((imported) => imported.external && !isBuiltin(imported.path))
|
||||
.map((imported) => imported.path),
|
||||
),
|
||||
].toSorted();
|
||||
return { javascript, css: result.outputFiles?.find((file) => file.path.endsWith(".css"))?.text ?? "", hostModules };
|
||||
}
|
||||
|
||||
async function readDependencyLock(directory?: string) {
|
||||
if (!directory) return undefined;
|
||||
try {
|
||||
return await readFile(join(directory, "package-lock.json"), "utf8");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export { hashWidgetArtifactContent } from "./integrity";
|
||||
@@ -0,0 +1,70 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { CustomWidgetPackage } from "./schema";
|
||||
|
||||
export interface WidgetDependencyOptions {
|
||||
rootDirectory: string;
|
||||
allowDependencyInstall?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const installations = new Map<string, Promise<string>>();
|
||||
|
||||
export async function prepareCustomWidgetDependencies(
|
||||
widget: CustomWidgetPackage,
|
||||
digest: string,
|
||||
options: WidgetDependencyOptions,
|
||||
): Promise<string> {
|
||||
const directory = join(options.rootDirectory, "dependencies", digest);
|
||||
const pending = installations.get(directory);
|
||||
if (pending) return pending;
|
||||
const work = prepare(directory, widget, options).finally(() => installations.delete(directory));
|
||||
installations.set(directory, work);
|
||||
return work;
|
||||
}
|
||||
|
||||
async function prepare(
|
||||
directory: string,
|
||||
widget: CustomWidgetPackage,
|
||||
options: WidgetDependencyOptions,
|
||||
): Promise<string> {
|
||||
const manifest = { name: "homarr-trusted-widget", private: true, dependencies: widget.dependencies };
|
||||
try {
|
||||
const installed = await readFile(join(directory, ".ready"), "utf8");
|
||||
if (installed === JSON.stringify(manifest)) return directory;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
||||
}
|
||||
if (!options.allowDependencyInstall)
|
||||
throw new Error("This widget requires dependency installation after owner trust");
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(join(directory, "package.json"), JSON.stringify(manifest), { mode: 0o600 });
|
||||
await install(directory, options.signal);
|
||||
await writeFile(join(directory, ".ready"), JSON.stringify(manifest), { mode: 0o600 });
|
||||
return directory;
|
||||
}
|
||||
|
||||
function install(directory: string, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--omit=dev"], {
|
||||
cwd: directory,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
signal,
|
||||
timeout: 120_000,
|
||||
env: { ...process.env, npm_config_cache: join(directory, ".npm-cache") },
|
||||
});
|
||||
let output = "";
|
||||
const collect = (chunk: Buffer) => {
|
||||
output = `${output}${chunk.toString()}`.slice(-16_384);
|
||||
};
|
||||
child.stdout.on("data", collect);
|
||||
child.stderr.on("data", collect);
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Widget dependency installation failed (${code}): ${output}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./schema";
|
||||
export * from "./artifact";
|
||||
export * from "./archive";
|
||||
export * from "./collection";
|
||||
export * from "./preview-scenarios";
|
||||
export { stringifyWidgetJson } from "./json";
|
||||
export type { WidgetInvocationContext, WidgetSdkInvocation } from "./protocol";
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { customWidgetArtifactSchema } from "./artifact";
|
||||
import type { CustomWidgetArtifact } from "./artifact";
|
||||
import { customWidgetPackageSchema } from "./schema";
|
||||
import { stringifyWidgetJson } from "./json";
|
||||
|
||||
export function hashWidgetArtifactContent(value: unknown): string {
|
||||
return createHash("sha256").update(stringifyWidgetJson(value)).digest("hex");
|
||||
}
|
||||
|
||||
export function assertWidgetArtifactIntegrity(input: unknown, source?: unknown): CustomWidgetArtifact {
|
||||
const artifact = customWidgetArtifactSchema.parse(input);
|
||||
const { digest, ...contents } = artifact;
|
||||
if (hashWidgetArtifactContent(contents) !== digest)
|
||||
throw new Error("Widget artifact checksum does not match its contents");
|
||||
if (source !== undefined) {
|
||||
const parsed = customWidgetPackageSchema.parse(source);
|
||||
if (
|
||||
hashWidgetArtifactContent(parsed) !== artifact.sourceDigest ||
|
||||
stringifyWidgetJson(parsed.manifest) !== stringifyWidgetJson(artifact.manifest) ||
|
||||
stringifyWidgetJson(parsed.dependencies) !== stringifyWidgetJson(artifact.dependencyLock.dependencies)
|
||||
) {
|
||||
throw new Error("Widget artifact does not identify the supplied source package and manifest");
|
||||
}
|
||||
}
|
||||
return artifact;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Canonical code-unit key ordering shared by package identity, publishing and artifact checksums. */
|
||||
export function stringifyWidgetJson(value: unknown): string {
|
||||
return JSON.stringify(sortJson(value));
|
||||
}
|
||||
|
||||
function sortJson(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sortJson);
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([, child]) => child !== undefined)
|
||||
.toSorted(([left], [right]) => {
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map(([key, child]) => [key, sortJson(child)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { fork } from "node:child_process";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { WidgetChildMessage, WidgetHostMessage, WidgetInvocationContext, WidgetSdkCallback } from "./protocol";
|
||||
|
||||
export interface WidgetProcessInvocation extends WidgetInvocationContext {
|
||||
handler: string;
|
||||
kind: string;
|
||||
input: unknown;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
sdk?: WidgetSdkCallback;
|
||||
onData?(value: unknown): void;
|
||||
onError?(error: Error): void;
|
||||
onComplete?(): void;
|
||||
}
|
||||
|
||||
interface PendingInvocation {
|
||||
input: WidgetProcessInvocation;
|
||||
resolve(value: unknown): void;
|
||||
reject(error: Error): void;
|
||||
dispose(): void;
|
||||
cancelled: boolean;
|
||||
controller: AbortController;
|
||||
}
|
||||
|
||||
export class WidgetPackageProcess {
|
||||
private readonly child: ChildProcess;
|
||||
private readonly pending = new Map<string, PendingInvocation>();
|
||||
private heartbeatAt = Date.now();
|
||||
private readonly heartbeat: ReturnType<typeof setInterval>;
|
||||
private readyResolve!: () => void;
|
||||
private readyReject!: (error: Error) => void;
|
||||
private readonly ready: Promise<void>;
|
||||
private dead = false;
|
||||
private reservations = 0;
|
||||
private queued = 0;
|
||||
private readonly instanceGenerations = new Map<string, number>();
|
||||
public lastUsed = Date.now();
|
||||
|
||||
public constructor(runnerPath: string, serverPath: string, handlers: string[], log?: (message: string) => void) {
|
||||
this.ready = new Promise((resolve, reject) => {
|
||||
this.readyResolve = resolve;
|
||||
this.readyReject = reject;
|
||||
});
|
||||
this.child = fork(runnerPath, [serverPath], {
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
serialization: "advanced",
|
||||
execArgv: ["--max-old-space-size=256"],
|
||||
env: { ...process.env },
|
||||
});
|
||||
const startupTimeout = setTimeout(() => this.fail(new Error("Widget server startup exceeded 15 seconds")), 15_000);
|
||||
void this.ready.finally(() => clearTimeout(startupTimeout)).catch(() => undefined);
|
||||
const output = (chunk: Buffer) => log?.(chunk.toString().slice(0, 16_384));
|
||||
this.child.stdout?.on("data", output);
|
||||
this.child.stderr?.on("data", output);
|
||||
this.child.on("message", (message: WidgetChildMessage) => {
|
||||
if (message.type === "ready") {
|
||||
const missing = handlers.filter((handler) => !message.handlers.includes(handler));
|
||||
if (missing.length) this.fail(new Error(`Server does not export declared handlers: ${missing.join(", ")}`));
|
||||
else this.readyResolve();
|
||||
} else void this.receive(message);
|
||||
});
|
||||
this.child.on("error", (error) => this.fail(error));
|
||||
this.child.on("exit", (code, signal) => this.fail(new Error(`Widget server stopped (${signal ?? code})`)));
|
||||
this.heartbeat = setInterval(() => {
|
||||
if (Date.now() - this.heartbeatAt > 10_000) this.fail(new Error("Widget server stopped responding"));
|
||||
}, 2_000);
|
||||
this.heartbeat.unref();
|
||||
}
|
||||
|
||||
public get activeCount() {
|
||||
return this.pending.size + this.reservations + this.queued;
|
||||
}
|
||||
public get stopped() {
|
||||
return this.dead;
|
||||
}
|
||||
public waitUntilReady() {
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
public async invoke(input: WidgetProcessInvocation): Promise<unknown> {
|
||||
const generation = this.instanceGenerations.get(input.instanceId) ?? 0;
|
||||
await this.ready;
|
||||
await this.waitForCapacity(input.signal);
|
||||
this.assertCurrentGeneration(input, generation);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.begin(input, resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
public async subscribe(input: WidgetProcessInvocation): Promise<() => void> {
|
||||
const generation = this.instanceGenerations.get(input.instanceId) ?? 0;
|
||||
await this.ready;
|
||||
await this.waitForCapacity(input.signal);
|
||||
this.assertCurrentGeneration(input, generation);
|
||||
const id = this.begin(
|
||||
input,
|
||||
() => input.onComplete?.(),
|
||||
(error) => input.onError?.(error),
|
||||
);
|
||||
return () => this.cancel(id, new Error("Subscription closed"), false);
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.fail(new Error("Widget server disabled"));
|
||||
}
|
||||
|
||||
public cancelInstances(instanceIds: ReadonlySet<string>, options: { actions?: boolean } = {}) {
|
||||
for (const id of instanceIds) this.instanceGenerations.set(id, (this.instanceGenerations.get(id) ?? 0) + 1);
|
||||
for (const [id, invocation] of this.pending) {
|
||||
if (!instanceIds.has(invocation.input.instanceId)) continue;
|
||||
if (options.actions === false && ["action", "migration"].includes(invocation.input.kind)) continue;
|
||||
this.cancel(id, new Error("Widget placement was stopped"));
|
||||
}
|
||||
}
|
||||
|
||||
private assertCurrentGeneration(input: WidgetProcessInvocation, generation: number) {
|
||||
if ((this.instanceGenerations.get(input.instanceId) ?? 0) === generation) return;
|
||||
this.reservations -= 1;
|
||||
throw new Error("Widget placement was stopped while waiting for server capacity");
|
||||
}
|
||||
|
||||
private async waitForCapacity(signal?: AbortSignal) {
|
||||
if (this.queued >= 64) throw Object.assign(new Error("Widget server queue is full"), { code: "TOO_MANY_REQUESTS" });
|
||||
this.queued += 1;
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
while (this.pending.size + this.reservations >= 8) {
|
||||
signal?.throwIfAborted();
|
||||
if (this.dead) throw new Error("Widget server is unavailable");
|
||||
if (Date.now() - startedAt > 30_000) throw new Error("Widget server queue wait exceeded 30 seconds");
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (this.dead) throw new Error("Widget invocation unavailable");
|
||||
this.reservations += 1;
|
||||
} finally {
|
||||
this.queued -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
private begin(
|
||||
input: WidgetProcessInvocation,
|
||||
resolve: PendingInvocation["resolve"],
|
||||
reject: PendingInvocation["reject"],
|
||||
) {
|
||||
const id = randomUUID();
|
||||
this.reservations -= 1;
|
||||
if (this.dead || input.signal?.aborted) {
|
||||
reject(
|
||||
input.signal?.reason instanceof Error ? input.signal.reason : new Error("Widget server invocation unavailable"),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
this.lastUsed = Date.now();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
if (input.kind !== "subscription")
|
||||
timeout = setTimeout(() => this.cancel(id, new Error("Widget handler exceeded its deadline")), input.timeoutMs);
|
||||
const abort = () =>
|
||||
this.cancel(
|
||||
id,
|
||||
input.signal?.reason instanceof Error ? input.signal.reason : new Error("Widget invocation cancelled"),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
input.signal?.addEventListener("abort", abort, { once: true });
|
||||
this.pending.set(id, {
|
||||
input,
|
||||
resolve,
|
||||
reject,
|
||||
cancelled: false,
|
||||
controller,
|
||||
dispose: () => {
|
||||
controller.abort();
|
||||
clearTimeout(timeout);
|
||||
input.signal?.removeEventListener("abort", abort);
|
||||
},
|
||||
});
|
||||
this.send({
|
||||
type: "invoke",
|
||||
id,
|
||||
handler: input.handler,
|
||||
kind: input.kind,
|
||||
input: input.input,
|
||||
context: { instanceId: input.instanceId, boardId: input.boardId, userId: input.userId },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
private cancel(id: string, error: Error, notify = true) {
|
||||
const request = this.pending.get(id);
|
||||
if (!request) return;
|
||||
if (request.cancelled) return;
|
||||
request.cancelled = true;
|
||||
request.dispose();
|
||||
if (notify) request.reject(error);
|
||||
// Keep capacity reserved until the child acknowledges cancellation or is terminated.
|
||||
request.resolve = () => undefined;
|
||||
request.reject = () => undefined;
|
||||
this.send({ type: "cancel", id });
|
||||
const grace = setTimeout(() => {
|
||||
if (this.pending.has(id)) this.fail(new Error("Widget ignored cancellation"));
|
||||
}, 2_000);
|
||||
grace.unref();
|
||||
}
|
||||
|
||||
private async receive(message: Exclude<WidgetChildMessage, { type: "ready" }>) {
|
||||
this.heartbeatAt = Date.now();
|
||||
if (message.type === "heartbeat") return;
|
||||
if (message.type === "fatal") {
|
||||
this.fail(new Error(message.error));
|
||||
return;
|
||||
}
|
||||
if (message.type === "sdk") {
|
||||
const invocation = this.pending.get(message.invocationId);
|
||||
if (!invocation?.input.sdk || invocation.cancelled) {
|
||||
this.send({ type: "sdk-result", id: message.id, error: "Widget SDK invocation unavailable" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const input = invocation.input;
|
||||
const value = await input.sdk?.({
|
||||
operation: message.operation,
|
||||
input: message.input,
|
||||
signal: invocation.controller.signal,
|
||||
context: { instanceId: input.instanceId, boardId: input.boardId, userId: input.userId },
|
||||
});
|
||||
this.send({ type: "sdk-result", id: message.id, value });
|
||||
} catch (error) {
|
||||
this.send({
|
||||
type: "sdk-result",
|
||||
id: message.id,
|
||||
error: error instanceof Error ? error.message : "Widget SDK call failed",
|
||||
errorCode:
|
||||
error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const invocation = this.pending.get(message.id);
|
||||
if (!invocation) return;
|
||||
if (message.type === "event") {
|
||||
if (invocation.cancelled) return;
|
||||
try {
|
||||
invocation.input.onData?.(message.value);
|
||||
} catch (error) {
|
||||
this.cancel(message.id, error instanceof Error ? error : new Error("Subscription consumer failed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.pending.delete(message.id);
|
||||
invocation.dispose();
|
||||
this.lastUsed = Date.now();
|
||||
if (message.error) invocation.reject(Object.assign(new Error(message.error), { code: message.errorCode }));
|
||||
else invocation.resolve(message.value);
|
||||
}
|
||||
|
||||
private send(message: WidgetHostMessage) {
|
||||
if (this.dead || !this.child.connected) return;
|
||||
try {
|
||||
if (Buffer.byteLength(JSON.stringify(message), "utf8") > 8 * 1024 * 1024)
|
||||
throw new Error("Widget IPC payload exceeds 8 MiB");
|
||||
this.child.send(message, (error) => {
|
||||
if (error) this.fail(error);
|
||||
});
|
||||
} catch (error) {
|
||||
this.fail(error instanceof Error ? error : new Error("Widget IPC send failed"));
|
||||
}
|
||||
}
|
||||
|
||||
private fail(error: Error) {
|
||||
if (this.dead) return;
|
||||
this.dead = true;
|
||||
clearInterval(this.heartbeat);
|
||||
this.readyReject(error);
|
||||
for (const invocation of this.pending.values()) {
|
||||
invocation.dispose();
|
||||
invocation.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.child.kill("SIGKILL");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const name = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z][A-Za-z0-9._-]*$/u)
|
||||
.refine((value) => !["__proto__", "prototype", "constructor"].includes(value), "Reserved fixture name");
|
||||
const delayMs = z.number().int().min(0).max(30_000).optional();
|
||||
|
||||
export const widgetPreviewQueryFixtureSchema = z.union([
|
||||
z.strictObject({ data: z.json(), delayMs }),
|
||||
z.strictObject({
|
||||
error: z.strictObject({
|
||||
message: z.string().min(1).max(2048),
|
||||
code: z.enum(["BAD_REQUEST", "NOT_FOUND", "TIMEOUT", "INTERNAL_SERVER_ERROR"]).optional(),
|
||||
}),
|
||||
delayMs,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const widgetPreviewScenarioSchema = z.strictObject({
|
||||
label: z.string().trim().min(1).max(128),
|
||||
description: z.string().max(2048).optional(),
|
||||
queries: z
|
||||
.record(name, widgetPreviewQueryFixtureSchema)
|
||||
.refine((queries) => Object.keys(queries).length <= 64, "A scenario supports at most 64 query fixtures"),
|
||||
});
|
||||
|
||||
export const widgetPreviewScenariosSchema = z
|
||||
.record(name, widgetPreviewScenarioSchema)
|
||||
.refine((scenarios) => Object.keys(scenarios).length <= 16, "A package supports at most 16 preview scenarios")
|
||||
.refine(
|
||||
(scenarios) => new TextEncoder().encode(JSON.stringify(scenarios)).length <= 2 * 1024 * 1024,
|
||||
"Preview scenarios exceed the 2 MiB limit",
|
||||
);
|
||||
|
||||
export type WidgetPreviewScenario = z.infer<typeof widgetPreviewScenarioSchema>;
|
||||
export type WidgetPreviewQueryFixture = z.infer<typeof widgetPreviewQueryFixtureSchema>;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, extname, join, relative, resolve } from "node:path";
|
||||
|
||||
import { customWidgetArchiveSchema } from "./archive";
|
||||
import { assertWidgetArtifactIntegrity } from "./integrity";
|
||||
import { customWidgetPackageSchema } from "./schema";
|
||||
import type { CustomWidgetPackage } from "./schema";
|
||||
|
||||
export const WIDGET_PROJECT_MANIFEST = "homarr-widget.json";
|
||||
const ignored = new Set(["node_modules", ".git", ".homarr-widgets", "dist", "coverage"]);
|
||||
const sourceExtensions = new Set([
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".json",
|
||||
".css",
|
||||
".svg",
|
||||
".txt",
|
||||
".md",
|
||||
]);
|
||||
const binaryTypes: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
export async function loadCustomWidgetProject(inputPath: string) {
|
||||
const path = resolve(inputPath);
|
||||
const directory = (await stat(path)).isDirectory();
|
||||
const manifestPath = directory ? join(path, WIDGET_PROJECT_MANIFEST) : path;
|
||||
const input: unknown = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
const archive = customWidgetArchiveSchema.safeParse(input);
|
||||
if (archive.success) {
|
||||
assertWidgetArtifactIntegrity(archive.data.artifact, archive.data.source);
|
||||
return { source: archive.data.source, archive: archive.data, projectDirectory: dirname(manifestPath) };
|
||||
}
|
||||
if (!directory) return { source: customWidgetPackageSchema.parse(input), projectDirectory: dirname(path) };
|
||||
if (input === null || typeof input !== "object" || Array.isArray(input))
|
||||
throw new Error("Invalid widget project manifest");
|
||||
const files: Record<string, string> = {};
|
||||
let size = 0;
|
||||
async function collect(current: string) {
|
||||
for (const entry of await readdir(current, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith(".") || ignored.has(entry.name) || entry.isSymbolicLink()) continue;
|
||||
const filePath = join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await collect(filePath);
|
||||
continue;
|
||||
}
|
||||
const name = relative(path, filePath).replaceAll("\\", "/");
|
||||
if (name === WIDGET_PROJECT_MANIFEST || ["package.json", "package-lock.json", "tsconfig.json"].includes(name))
|
||||
continue;
|
||||
if (name.endsWith(".artifact.json") || name.endsWith(".homarr-widget.json")) continue;
|
||||
const extension = extname(name);
|
||||
if (!sourceExtensions.has(extension) && !binaryTypes[extension]) continue;
|
||||
const metadata = await stat(filePath);
|
||||
size += metadata.size;
|
||||
if (metadata.size > 5_000_000 || size > 20_000_000 || Object.keys(files).length >= 256)
|
||||
throw new Error("Widget project exceeds package size limits");
|
||||
const content = await readFile(filePath);
|
||||
files[name] = content.toString("utf8");
|
||||
if (binaryTypes[extension]) files[name] = `data:${binaryTypes[extension]};base64,${content.toString("base64")}`;
|
||||
}
|
||||
}
|
||||
await collect(path);
|
||||
return { source: customWidgetPackageSchema.parse({ ...input, files }), projectDirectory: path };
|
||||
}
|
||||
|
||||
export async function writeCustomWidgetProject(input: CustomWidgetPackage, directory: string) {
|
||||
const source = customWidgetPackageSchema.parse(input);
|
||||
if (Object.hasOwn(source.files, WIDGET_PROJECT_MANIFEST))
|
||||
throw new Error(`Package source cannot overwrite ${WIDGET_PROJECT_MANIFEST}`);
|
||||
const target = resolve(directory);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await mkdir(target); // Refuse existing destinations; scaffolding never overwrites someone else's project.
|
||||
for (const [path, content] of Object.entries(source.files)) {
|
||||
const filePath = join(target, path);
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
let value: string | Buffer = content;
|
||||
if (binaryTypes[extname(path)] && /^data:[^,]+;base64,/u.test(content))
|
||||
value = Buffer.from(content.slice(content.indexOf(",") + 1), "base64");
|
||||
await writeFile(filePath, value, { flag: "wx", mode: 0o600 });
|
||||
}
|
||||
const { files: _files, ...manifest } = source;
|
||||
await writeFile(join(target, WIDGET_PROJECT_MANIFEST), JSON.stringify(manifest, null, 2) + "\n", {
|
||||
flag: "wx",
|
||||
mode: 0o600,
|
||||
});
|
||||
if (!Object.hasOwn(source.files, ".gitignore"))
|
||||
await writeFile(
|
||||
join(target, ".gitignore"),
|
||||
".homarr-widget.local.json\n.homarr-widgets/\ndist/\nnode_modules/\n.env*\n",
|
||||
{ flag: "wx" },
|
||||
);
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface WidgetInvocationContext {
|
||||
instanceId: string;
|
||||
boardId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface WidgetSdkInvocation {
|
||||
operation: string;
|
||||
input: unknown;
|
||||
context: WidgetInvocationContext;
|
||||
/** Aborts on completion, retirement, timeout or worker failure; never sent over IPC. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type WidgetSdkCallback = (invocation: WidgetSdkInvocation) => Promise<unknown>;
|
||||
|
||||
export type WidgetHostMessage =
|
||||
| { type: "invoke"; id: string; handler: string; kind: string; input: unknown; context: WidgetInvocationContext }
|
||||
| { type: "cancel"; id: string }
|
||||
| { type: "sdk-result"; id: string; value?: unknown; error?: string; errorCode?: string }
|
||||
| { type: "shutdown" };
|
||||
|
||||
export type WidgetChildMessage =
|
||||
| { type: "ready"; handlers: string[] }
|
||||
| { type: "heartbeat" }
|
||||
| { type: "result"; id: string; value?: unknown; error?: string; errorCode?: string }
|
||||
| { type: "event"; id: string; value: unknown }
|
||||
| { type: "sdk"; id: string; invocationId: string; operation: string; input: unknown }
|
||||
| { type: "fatal"; error: string };
|
||||
@@ -0,0 +1,97 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
interface RunnerStream {
|
||||
values: unknown[];
|
||||
bytes: number;
|
||||
done: boolean;
|
||||
error?: string;
|
||||
updatedAt: number;
|
||||
wake?: () => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/** A pull transport keeps slow remote viewers from creating an unbounded IPC or HTTP queue. */
|
||||
export class WidgetRunnerStreams {
|
||||
private readonly streams = new Map<string, RunnerStream>();
|
||||
private readonly cleanup = setInterval(() => {
|
||||
for (const [id, stream] of this.streams) if (Date.now() - stream.updatedAt > 60_000) this.cancel(id);
|
||||
}, 15_000);
|
||||
|
||||
public create() {
|
||||
if (this.streams.size >= 32) throw new Error("Runner subscription capacity is full");
|
||||
const id = randomUUID();
|
||||
const stream: RunnerStream = { values: [], bytes: 0, done: false, updatedAt: Date.now(), cancel: () => undefined };
|
||||
this.streams.set(id, stream);
|
||||
return {
|
||||
id,
|
||||
setCancel: (cancel: () => void) => {
|
||||
stream.cancel = cancel;
|
||||
if (stream.done) cancel();
|
||||
},
|
||||
onData: (value: unknown) => {
|
||||
if (stream.done) return;
|
||||
const bytes = Buffer.byteLength(JSON.stringify(value) ?? "null");
|
||||
if (stream.values.length >= 32 || stream.bytes + bytes > 8_388_608) {
|
||||
stream.error = "Remote subscription exceeded its buffer limit";
|
||||
stream.done = true;
|
||||
stream.cancel();
|
||||
} else {
|
||||
stream.values.push(value);
|
||||
stream.bytes += bytes;
|
||||
}
|
||||
stream.wake?.();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
stream.error = error.message;
|
||||
stream.done = true;
|
||||
stream.wake?.();
|
||||
},
|
||||
onComplete: () => {
|
||||
stream.done = true;
|
||||
stream.wake?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async next(id: string, signal: AbortSignal) {
|
||||
const stream = this.streams.get(id);
|
||||
if (!stream) throw new Error("Runner subscription expired");
|
||||
if (stream.wake) throw new Error("Only one subscription read may be active");
|
||||
stream.updatedAt = Date.now();
|
||||
if (stream.values.length === 0 && !stream.done && !signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const wake = () => {
|
||||
clearTimeout(timeout);
|
||||
signal.removeEventListener("abort", wake);
|
||||
stream.wake = undefined;
|
||||
resolve();
|
||||
};
|
||||
const timeout = setTimeout(wake, 10_000);
|
||||
stream.wake = wake;
|
||||
signal.addEventListener("abort", wake, { once: true });
|
||||
});
|
||||
}
|
||||
if (stream.error) {
|
||||
this.cancel(id);
|
||||
throw new Error(stream.error);
|
||||
}
|
||||
const values = stream.values.splice(0);
|
||||
stream.bytes = 0;
|
||||
if (stream.done) this.cancel(id);
|
||||
return { done: stream.done, values };
|
||||
}
|
||||
|
||||
public cancel(id: string) {
|
||||
const stream = this.streams.get(id);
|
||||
if (!stream) return;
|
||||
this.streams.delete(id);
|
||||
stream.done = true;
|
||||
stream.cancel();
|
||||
stream.wake?.();
|
||||
}
|
||||
|
||||
public close() {
|
||||
clearInterval(this.cleanup);
|
||||
for (const id of this.streams.keys()) this.cancel(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { CustomWidgetArtifact } from "./artifact";
|
||||
import { assertWidgetArtifactIntegrity } from "./integrity";
|
||||
import { WidgetRunnerStreams } from "./runner-streams";
|
||||
import { CustomWidgetPackageSupervisor } from "./supervisor";
|
||||
|
||||
const invocationSchema = z.object({
|
||||
handler: z.string().min(1).max(128),
|
||||
input: z.unknown().default({}),
|
||||
instanceId: z.string().min(1).max(128),
|
||||
boardId: z.string().min(1).max(128),
|
||||
userId: z.string().max(128).optional(),
|
||||
});
|
||||
const streamSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
export interface WidgetRunnerOptions {
|
||||
artifact: CustomWidgetArtifact;
|
||||
rootDirectory: string;
|
||||
token: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
log?(message: string): void;
|
||||
}
|
||||
|
||||
/** A trusted remote execution location. This is not a sandbox and exposes no package upload endpoint. */
|
||||
export async function startWidgetPackageRunner(options: WidgetRunnerOptions) {
|
||||
if (options.token.length < 32) throw new Error("Runner tokens must contain at least 32 characters");
|
||||
const artifact = assertWidgetArtifactIntegrity(options.artifact);
|
||||
const supervisor = new CustomWidgetPackageSupervisor({ rootDirectory: options.rootDirectory, log: options.log });
|
||||
try {
|
||||
await supervisor.preflight({ artifact });
|
||||
} catch (error) {
|
||||
await supervisor.shutdown();
|
||||
throw error;
|
||||
}
|
||||
const streams = new WidgetRunnerStreams();
|
||||
const expected = Buffer.from(`Bearer ${options.token}`);
|
||||
const server = createServer({ requestTimeout: 30_000, headersTimeout: 10_000 }, (request, response) => {
|
||||
void handle(request, response).catch((error: unknown) => {
|
||||
if (response.destroyed) return;
|
||||
response.statusCode = 400;
|
||||
response.end(JSON.stringify({ error: error instanceof Error ? error.message : "Runner request failed" }));
|
||||
});
|
||||
});
|
||||
|
||||
async function handle(request: IncomingMessage, response: ServerResponse) {
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.setHeader("cache-control", "no-store");
|
||||
const authorization = Buffer.from(request.headers.authorization ?? "");
|
||||
if (authorization.length !== expected.length || !timingSafeEqual(authorization, expected)) {
|
||||
response.statusCode = 401;
|
||||
response.end(JSON.stringify({ error: "Runner authentication required" }));
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && request.url === "/health") {
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
packageId: artifact.manifest.id,
|
||||
version: artifact.manifest.version,
|
||||
digest: artifact.digest,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.method !== "POST") {
|
||||
response.statusCode = 405;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
response.once("close", () => controller.abort());
|
||||
const input = await readBody(request);
|
||||
if (request.url === "/subscriptions/next") {
|
||||
response.end(JSON.stringify(await streams.next(streamSchema.parse(input).id, controller.signal)));
|
||||
return;
|
||||
}
|
||||
if (request.url === "/subscriptions/cancel") {
|
||||
streams.cancel(streamSchema.parse(input).id);
|
||||
response.end(JSON.stringify({ done: true }));
|
||||
return;
|
||||
}
|
||||
if (request.url !== "/invoke" && request.url !== "/subscriptions/start") {
|
||||
response.statusCode = 404;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const invocation = invocationSchema.parse(input);
|
||||
const descriptor = artifact.manifest.handlers[invocation.handler];
|
||||
if (!descriptor || descriptor.kind === "migration") throw new Error("Unknown remotely callable handler");
|
||||
if (request.url === "/invoke") {
|
||||
const value = await supervisor.invoke({ ...invocation, artifact, signal: controller.signal });
|
||||
response.end(JSON.stringify({ value }));
|
||||
return;
|
||||
}
|
||||
const stream = streams.create();
|
||||
try {
|
||||
const cancel = await supervisor.subscribe({ ...invocation, artifact, ...stream });
|
||||
stream.setCancel(cancel);
|
||||
response.end(JSON.stringify({ id: stream.id }));
|
||||
} catch (error) {
|
||||
streams.cancel(stream.id);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(options.port ?? 8787, options.host ?? "127.0.0.1", () => {
|
||||
server.removeListener("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
streams.close();
|
||||
await supervisor.shutdown();
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
address: server.address(),
|
||||
close: async () => {
|
||||
const closed = new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
server.closeAllConnections();
|
||||
streams.close();
|
||||
await supervisor.shutdown();
|
||||
await closed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readBody(request: IncomingMessage) {
|
||||
const chunks: Buffer[] = [];
|
||||
let length = 0;
|
||||
for await (const value of request) {
|
||||
const chunk = Buffer.from(value);
|
||||
length += chunk.byteLength;
|
||||
if (length > 1_048_576) throw new Error("Runner request exceeds 1 MiB");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { customWidgetOptionsSchema } from "../core/options-schema";
|
||||
import { widgetPreviewScenariosSchema } from "./preview-scenarios";
|
||||
|
||||
export const CUSTOM_WIDGET_PACKAGE_SCHEMA = "homarr-widget-package-v3";
|
||||
export const CUSTOM_WIDGET_SDK_VERSION = "1";
|
||||
const identifier = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
|
||||
const exactVersion = z.string().regex(/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$/u);
|
||||
const packageName = z.string().regex(/^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/u);
|
||||
const safeKeys = (value: Record<string, unknown>) =>
|
||||
Object.keys(value).every((key) => !["__proto__", "prototype", "constructor"].includes(key));
|
||||
|
||||
export const widgetPackagePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(240)
|
||||
.refine(
|
||||
(path) =>
|
||||
!path.startsWith("/") &&
|
||||
!path.includes("\\") &&
|
||||
!path.includes("\0") &&
|
||||
path.split("/").every((part) => part !== ".." && part !== "." && part.length > 0),
|
||||
"Package files must use relative paths without traversal",
|
||||
);
|
||||
|
||||
export const widgetPackageHandlerSchema = z
|
||||
.strictObject({
|
||||
kind: z.enum(["query", "action", "subscription", "migration"]),
|
||||
permission: z.enum(["view", "modify", "full"]).optional(),
|
||||
description: z.string().max(2048).optional(),
|
||||
inputSchema: z.record(z.string(), z.unknown()).optional(),
|
||||
guestAccess: z.boolean().default(false),
|
||||
timeoutMs: z.number().int().min(100).max(300_000).default(30_000),
|
||||
})
|
||||
.transform((handler) => {
|
||||
let permission = handler.permission;
|
||||
if (!permission) {
|
||||
permission = "view";
|
||||
if (handler.kind === "action") permission = "modify";
|
||||
if (handler.kind === "migration") permission = "full";
|
||||
}
|
||||
return { ...handler, permission };
|
||||
});
|
||||
|
||||
export const widgetPackageManifestSchema = z.strictObject({
|
||||
id: identifier,
|
||||
version: exactVersion,
|
||||
sdkVersion: z.literal(CUSTOM_WIDGET_SDK_VERSION),
|
||||
name: z.string().trim().min(1).max(128),
|
||||
description: z.string().max(2048).optional(),
|
||||
author: z.string().max(256).optional(),
|
||||
license: z.string().max(128).optional(),
|
||||
repository: z.string().url().optional(),
|
||||
icon: z.string().max(2048).optional(),
|
||||
screenshots: z.array(z.string().max(2048)).max(16).default([]),
|
||||
tags: z.array(z.string().max(64)).max(32).default([]),
|
||||
supportedApps: z.array(z.string().max(128)).max(32).default([]),
|
||||
storageVersion: z.number().int().positive().default(1),
|
||||
configurationVersion: z.number().int().positive().default(1),
|
||||
// Absence means scoped; keep it absent when parsing older archives so their checksums remain valid.
|
||||
styles: z.enum(["scoped", "global"]).optional(),
|
||||
migrations: z.strictObject({ configuration: identifier.optional(), storage: identifier.optional() }).optional(),
|
||||
entrypoints: z.strictObject({
|
||||
tile: widgetPackagePathSchema,
|
||||
advanced: widgetPackagePathSchema.optional(),
|
||||
configuration: widgetPackagePathSchema.optional(),
|
||||
server: widgetPackagePathSchema.optional(),
|
||||
}),
|
||||
handlers: z.record(identifier, widgetPackageHandlerSchema).refine(safeKeys).default({}),
|
||||
});
|
||||
|
||||
export const widgetPackageConnectionSchema = z
|
||||
.strictObject({
|
||||
label: z.string().trim().min(1).max(128),
|
||||
kind: z.enum(["http", "integration", "service"]),
|
||||
serviceType: identifier.optional(),
|
||||
optional: z.boolean().default(false),
|
||||
description: z.string().max(2048).optional(),
|
||||
})
|
||||
.refine((connection) => connection.kind !== "service" || Boolean(connection.serviceType), {
|
||||
path: ["serviceType"],
|
||||
message: "Service connections must declare a serviceType, such as mqtt, sqlite, ssh or custom",
|
||||
});
|
||||
|
||||
/** Shared by binding pickers, activation and portable collection imports. */
|
||||
export function isWidgetConnectionCompatible(
|
||||
requirement: { kind: string; serviceType?: string },
|
||||
configuration: { kind?: unknown; serviceType?: unknown },
|
||||
) {
|
||||
if (requirement.kind !== configuration.kind) return false;
|
||||
if (requirement.kind === "service") return requirement.serviceType === configuration.serviceType;
|
||||
return true;
|
||||
}
|
||||
|
||||
export const customWidgetPackageSchema = z
|
||||
.strictObject({
|
||||
$schema: z.literal(CUSTOM_WIDGET_PACKAGE_SCHEMA),
|
||||
manifest: widgetPackageManifestSchema,
|
||||
files: z
|
||||
.record(widgetPackagePathSchema, z.string().max(5_000_000))
|
||||
.refine(safeKeys)
|
||||
.refine((files) => Object.keys(files).length <= 256, "A package may contain at most 256 source files")
|
||||
.refine(
|
||||
(files) => Object.values(files).reduce((size, file) => size + file.length, 0) <= 20_000_000,
|
||||
"Package source exceeds the 20 MB limit",
|
||||
),
|
||||
dependencies: z.record(packageName, exactVersion).refine(safeKeys).default({}),
|
||||
connections: z.record(identifier, widgetPackageConnectionSchema).refine(safeKeys).default({}),
|
||||
options: customWidgetOptionsSchema,
|
||||
// Keep absent on older packages so their source and archive checksums remain unchanged.
|
||||
previewScenarios: widgetPreviewScenariosSchema.optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
for (const [surface, path] of Object.entries(value.manifest.entrypoints)) {
|
||||
if (!Object.hasOwn(value.files, path)) {
|
||||
ctx.addIssue({ code: "custom", path: ["manifest", "entrypoints", surface], message: `Missing file '${path}'` });
|
||||
}
|
||||
}
|
||||
if (Object.keys(value.manifest.handlers).length > 0 && !value.manifest.entrypoints.server) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["manifest", "entrypoints", "server"],
|
||||
message: "Handlers need a server entrypoint",
|
||||
});
|
||||
}
|
||||
for (const [kind, handler] of Object.entries(value.manifest.migrations ?? {})) {
|
||||
if (value.manifest.handlers[handler]?.kind !== "migration") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["manifest", "migrations", kind],
|
||||
message: "Migration entries must name a migration handler",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [scenario, fixture] of Object.entries(value.previewScenarios ?? {})) {
|
||||
for (const name of Object.keys(fixture.queries)) {
|
||||
if (value.manifest.handlers[name]?.kind !== "query")
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["previewScenarios", scenario, "queries", name],
|
||||
message: "Preview query fixtures must reference declared query handlers",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type CustomWidgetPackage = z.infer<typeof customWidgetPackageSchema>;
|
||||
export type CustomWidgetPackageInput = z.input<typeof customWidgetPackageSchema>;
|
||||
export type WidgetPackageManifest = z.infer<typeof widgetPackageManifestSchema>;
|
||||
export type WidgetPackageHandler = z.infer<typeof widgetPackageHandlerSchema>;
|
||||
export type WidgetPackageSurface = "tile" | "advanced" | "configuration";
|
||||
|
||||
export const HOST_WIDGET_MODULES = [
|
||||
"react",
|
||||
"react/jsx-runtime",
|
||||
"react/jsx-dev-runtime",
|
||||
"react-dom",
|
||||
"react-dom/client",
|
||||
"@mantine/core",
|
||||
"@mantine/hooks",
|
||||
"@mantine/charts",
|
||||
"@mantine/dates",
|
||||
"@tanstack/react-query",
|
||||
"@homarr/widget-sdk",
|
||||
"@homarr/widget-sdk/client",
|
||||
"@homarr/widget-sdk/ui",
|
||||
"@homarr/widget-sdk/table",
|
||||
"@homarr/widget-sdk/legacy",
|
||||
] as const;
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./compiler";
|
||||
export * from "./dependencies";
|
||||
export { assertWidgetArtifactIntegrity } from "./integrity";
|
||||
export * from "./supervisor";
|
||||
export * from "./runner";
|
||||
export * from "./project";
|
||||
export * from "./collection-archive";
|
||||
export type { WidgetSdkCallback, WidgetSdkInvocation, WidgetInvocationContext } from "./protocol";
|
||||
@@ -0,0 +1,217 @@
|
||||
import { mkdir, rename, writeFile } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { CustomWidgetArtifact } from "./artifact";
|
||||
import { getWidgetChildEntrypoint } from "./child-runner";
|
||||
import { WidgetPackageProcess } from "./package-process";
|
||||
import { assertWidgetArtifactIntegrity } from "./integrity";
|
||||
import type { WidgetInvocationContext, WidgetSdkCallback } from "./protocol";
|
||||
|
||||
export interface WidgetPackageInvocation extends WidgetInvocationContext {
|
||||
artifact: CustomWidgetArtifact;
|
||||
handler: string;
|
||||
input: unknown;
|
||||
signal?: AbortSignal;
|
||||
sdk?: WidgetSdkCallback;
|
||||
}
|
||||
|
||||
export interface WidgetPackageSupervisorOptions {
|
||||
rootDirectory: string;
|
||||
sdk?: WidgetSdkCallback;
|
||||
log?(message: string): void;
|
||||
maximumProcesses?: number;
|
||||
}
|
||||
|
||||
export class CustomWidgetPackageSupervisor {
|
||||
private readonly processes = new Map<string, Promise<WidgetPackageProcess>>();
|
||||
private readonly failures = new Map<string, number[]>();
|
||||
private readonly recordedFailures = new WeakSet<WidgetPackageProcess>();
|
||||
private readonly instanceGenerations = new Map<string, number>();
|
||||
private readonly reservations = new Map<string, number>();
|
||||
private readonly cleanup: ReturnType<typeof setInterval>;
|
||||
|
||||
public constructor(private readonly options: WidgetPackageSupervisorOptions) {
|
||||
this.cleanup = setInterval(() => void this.removeIdle(), 30_000);
|
||||
this.cleanup.unref();
|
||||
}
|
||||
|
||||
public async invoke(input: WidgetPackageInvocation): Promise<unknown> {
|
||||
const descriptor = input.artifact.manifest.handlers[input.handler];
|
||||
if (!descriptor || descriptor.kind === "subscription") throw new Error("Unknown query or action handler");
|
||||
const generation = this.instanceGenerations.get(input.instanceId) ?? 0;
|
||||
const release = this.reserveProcess(input.artifact.digest);
|
||||
let worker: WidgetPackageProcess | undefined;
|
||||
try {
|
||||
worker = await this.getProcess(input.artifact);
|
||||
this.assertCurrentInstance(input.instanceId, generation);
|
||||
return await worker.invoke({
|
||||
...input,
|
||||
kind: descriptor.kind,
|
||||
timeoutMs: descriptor.timeoutMs,
|
||||
sdk: input.sdk ?? this.options.sdk,
|
||||
});
|
||||
} catch (error) {
|
||||
if (worker?.stopped && !this.recordedFailures.has(worker)) {
|
||||
this.recordedFailures.add(worker);
|
||||
this.recordFailure(input.artifact.digest);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
public async preflight(input: { artifact: CustomWidgetArtifact }): Promise<void> {
|
||||
assertWidgetArtifactIntegrity(input.artifact);
|
||||
if (!input.artifact.server) return;
|
||||
const release = this.reserveProcess(input.artifact.digest);
|
||||
try {
|
||||
await this.getProcess(input.artifact);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
public async subscribe(
|
||||
input: WidgetPackageInvocation & { onData(value: unknown): void; onError(error: Error): void; onComplete?(): void },
|
||||
): Promise<() => void> {
|
||||
const descriptor = input.artifact.manifest.handlers[input.handler];
|
||||
if (descriptor?.kind !== "subscription") throw new Error("Unknown subscription handler");
|
||||
const generation = this.instanceGenerations.get(input.instanceId) ?? 0;
|
||||
const release = this.reserveProcess(input.artifact.digest);
|
||||
try {
|
||||
const worker = await this.getProcess(input.artifact);
|
||||
this.assertCurrentInstance(input.instanceId, generation);
|
||||
return await worker.subscribe({
|
||||
...input,
|
||||
kind: "subscription",
|
||||
timeoutMs: descriptor.timeoutMs,
|
||||
sdk: input.sdk ?? this.options.sdk,
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
public async disable(digest: string) {
|
||||
const pending = this.processes.get(digest);
|
||||
this.processes.delete(digest);
|
||||
if (pending) (await pending.catch(() => undefined))?.stop();
|
||||
}
|
||||
|
||||
public async cancelInstances(instanceIds: string[], options: { actions?: boolean } = {}): Promise<void> {
|
||||
for (const id of instanceIds) this.instanceGenerations.set(id, (this.instanceGenerations.get(id) ?? 0) + 1);
|
||||
const ids = new Set(instanceIds);
|
||||
await Promise.all(
|
||||
[...this.processes.values()].map(async (pending) => {
|
||||
const worker = await pending.catch(() => undefined);
|
||||
worker?.cancelInstances(ids, options);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async retry(digest: string) {
|
||||
await this.disable(digest);
|
||||
this.failures.delete(digest);
|
||||
}
|
||||
|
||||
public resetFailures(digest: string) {
|
||||
this.failures.delete(digest);
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
clearInterval(this.cleanup);
|
||||
await Promise.all([...this.processes.keys()].map((digest) => this.disable(digest)));
|
||||
}
|
||||
|
||||
private async getProcess(input: CustomWidgetArtifact): Promise<WidgetPackageProcess> {
|
||||
const artifact = assertWidgetArtifactIntegrity(input);
|
||||
if (!artifact.server) throw new Error("This widget has no server module");
|
||||
const existing = this.processes.get(artifact.digest);
|
||||
if (existing) {
|
||||
const worker = await existing.catch(() => undefined);
|
||||
if (this.processes.get(artifact.digest) !== existing) return this.getProcess(artifact);
|
||||
if (worker && !worker.stopped) return worker;
|
||||
this.processes.delete(artifact.digest);
|
||||
}
|
||||
const failures = (this.failures.get(artifact.digest) ?? []).filter((time) => Date.now() - time < 60_000);
|
||||
if (failures.length >= 3) throw new Error("Widget server repeatedly failed; retry it from widget management");
|
||||
if (this.processes.size >= (this.options.maximumProcesses ?? 8)) {
|
||||
await this.removeIdle(true);
|
||||
// Another caller may have started this artifact while idle eviction yielded.
|
||||
if (this.processes.has(artifact.digest)) return this.getProcess(artifact);
|
||||
if (this.processes.size >= (this.options.maximumProcesses ?? 8))
|
||||
throw new Error("Custom widget server capacity is full");
|
||||
}
|
||||
const worker = this.startAfterBackoff(artifact, failures.length).catch((error: unknown) => {
|
||||
this.recordFailure(artifact.digest);
|
||||
throw error;
|
||||
});
|
||||
this.processes.set(artifact.digest, worker);
|
||||
return worker;
|
||||
}
|
||||
|
||||
private async startAfterBackoff(artifact: CustomWidgetArtifact, failureCount: number) {
|
||||
if (failureCount > 0)
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(1_000 * 2 ** (failureCount - 1), 10_000)));
|
||||
return this.startProcess(artifact);
|
||||
}
|
||||
|
||||
private async startProcess(artifact: CustomWidgetArtifact) {
|
||||
const directory = join(this.options.rootDirectory, "runtime", artifact.digest);
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const runnerPath = join(directory, "runner.cjs");
|
||||
const serverPath = join(directory, "server.cjs");
|
||||
await this.writeRuntimeFile(runnerPath, getWidgetChildEntrypoint());
|
||||
await this.writeRuntimeFile(serverPath, artifact.server ?? "");
|
||||
const worker = new WidgetPackageProcess(
|
||||
runnerPath,
|
||||
serverPath,
|
||||
Object.keys(artifact.manifest.handlers),
|
||||
this.options.log,
|
||||
);
|
||||
await worker.waitUntilReady();
|
||||
return worker;
|
||||
}
|
||||
|
||||
private recordFailure(digest: string) {
|
||||
const failures = (this.failures.get(digest) ?? []).filter((time) => Date.now() - time < 60_000);
|
||||
failures.push(Date.now());
|
||||
this.failures.set(digest, failures);
|
||||
}
|
||||
|
||||
private assertCurrentInstance(id: string, generation: number) {
|
||||
if ((this.instanceGenerations.get(id) ?? 0) !== generation)
|
||||
throw new Error("Widget placement stopped during server startup");
|
||||
}
|
||||
|
||||
private async writeRuntimeFile(path: string, content: string) {
|
||||
const temporary = `${path}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporary, content, { mode: 0o600 });
|
||||
await rename(temporary, path);
|
||||
}
|
||||
|
||||
/** Reserve before asynchronous startup so eviction cannot overtake an accepted caller. */
|
||||
private reserveProcess(digest: string) {
|
||||
this.reservations.set(digest, (this.reservations.get(digest) ?? 0) + 1);
|
||||
return () => {
|
||||
const remaining = (this.reservations.get(digest) ?? 1) - 1;
|
||||
if (remaining > 0) this.reservations.set(digest, remaining);
|
||||
else this.reservations.delete(digest);
|
||||
};
|
||||
}
|
||||
|
||||
private async removeIdle(force = false) {
|
||||
for (const [digest, pending] of this.processes) {
|
||||
if (this.reservations.has(digest)) continue;
|
||||
const worker = await pending.catch(() => undefined);
|
||||
if (this.processes.get(digest) !== pending || this.reservations.has(digest)) continue;
|
||||
if (!worker || worker.stopped || (worker.activeCount === 0 && (force || Date.now() - worker.lastUsed > 60_000))) {
|
||||
this.processes.delete(digest);
|
||||
worker?.stop();
|
||||
if (force) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentType, ErrorInfo, ReactNode } from "react";
|
||||
import type { ComponentType, Dispatch, ErrorInfo, ReactNode, SetStateAction } from "react";
|
||||
import { Component, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Box, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
@@ -23,6 +23,8 @@ export interface CustomJsxRendererProps {
|
||||
data: unknown;
|
||||
status?: Record<string, unknown>;
|
||||
options?: Record<string, unknown>;
|
||||
host?: Record<string, unknown>;
|
||||
inputState?: readonly [CustomJsxInputState, Dispatch<SetStateAction<CustomJsxInputState>>];
|
||||
components: Readonly<Record<string, ComponentType<never>>>;
|
||||
createBindings(data: unknown): Readonly<Record<string, unknown>>;
|
||||
messages: CustomJsxRendererMessages;
|
||||
@@ -80,7 +82,7 @@ interface InputRegistration {
|
||||
initialValue: WidgetInputValue;
|
||||
}
|
||||
|
||||
interface InputState {
|
||||
export interface CustomJsxInputState {
|
||||
values: Record<string, WidgetInputValue>;
|
||||
types: Record<string, WidgetInputType>;
|
||||
}
|
||||
@@ -90,6 +92,8 @@ function CustomJsxRendererSession({
|
||||
data,
|
||||
status = EMPTY_RECORD,
|
||||
options = EMPTY_RECORD,
|
||||
host = EMPTY_RECORD,
|
||||
inputState: externalInputState,
|
||||
components,
|
||||
createBindings,
|
||||
messages,
|
||||
@@ -97,7 +101,10 @@ function CustomJsxRendererSession({
|
||||
const inputScopeId = useId();
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
const [bindingErrors, setBindingErrors] = useState<string[]>([]);
|
||||
const [inputState, setInputState] = useState<InputState>({ values: {}, types: {} });
|
||||
const [localInputState, setLocalInputState] = useState<CustomJsxInputState>({ values: {}, types: {} });
|
||||
const inputState = externalInputState?.[0] ?? localInputState;
|
||||
const setInputState = externalInputState?.[1] ?? setLocalInputState;
|
||||
const preserveInactiveInputs = externalInputState !== undefined;
|
||||
const { values: inputs, types: inputTypes } = inputState;
|
||||
const registrations = useRef(new Map<symbol, InputRegistration>());
|
||||
const registrationVersion = useRef(0);
|
||||
@@ -125,9 +132,9 @@ function CustomJsxRendererSession({
|
||||
}
|
||||
setBindingErrors((current) => (sameStringArray(current, conflicts) ? current : conflicts));
|
||||
setInputState((current) => {
|
||||
const nextTypes: Record<string, WidgetInputType> = {};
|
||||
const nextTypes: Record<string, WidgetInputType> = preserveInactiveInputs ? { ...current.types } : {};
|
||||
for (const [name, registration] of active) nextTypes[name] = registration.type;
|
||||
const nextValues: Record<string, WidgetInputValue> = {};
|
||||
const nextValues: Record<string, WidgetInputValue> = preserveInactiveInputs ? { ...current.values } : {};
|
||||
for (const [name, registration] of active) {
|
||||
if (current.types[name] === registration.type && Object.hasOwn(current.values, name)) {
|
||||
const previousValue = current.values[name];
|
||||
@@ -141,7 +148,7 @@ function CustomJsxRendererSession({
|
||||
if (sameTypeRecord(current.types, nextTypes) && sameInputRecord(current.values, nextValues)) return current;
|
||||
return { values: nextValues, types: nextTypes };
|
||||
});
|
||||
}, [bindingTypeConflict]);
|
||||
}, [bindingTypeConflict, preserveInactiveInputs, setInputState]);
|
||||
const registerInput = useCallback(
|
||||
(name: string, type: WidgetInputType, initialValue: WidgetInputValue) => {
|
||||
const id = Symbol(name);
|
||||
@@ -159,35 +166,41 @@ function CustomJsxRendererSession({
|
||||
},
|
||||
[reconcileInputs],
|
||||
);
|
||||
const setInputValue = useCallback((name: string, type: WidgetInputType, value: WidgetInputValue) => {
|
||||
setInputState((current) => {
|
||||
const existing = current.types[name];
|
||||
if (existing && existing !== type) return current;
|
||||
if (existing === type && Object.is(current.values[name], value)) return current;
|
||||
return {
|
||||
values: { ...current.values, [name]: value },
|
||||
types: existing === type ? current.types : { ...current.types, [name]: type },
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
const resetInput = useCallback((name: string, type: WidgetInputType) => {
|
||||
let activeRegistration: InputRegistration | undefined;
|
||||
for (const registration of registrations.current.values()) {
|
||||
if (registration.name !== name) continue;
|
||||
activeRegistration = registration;
|
||||
break;
|
||||
}
|
||||
if (!activeRegistration || activeRegistration.type !== type) return;
|
||||
setInputState((current) => {
|
||||
if (current.types[name] !== type || Object.is(current.values[name], activeRegistration.initialValue)) {
|
||||
return current;
|
||||
const setInputValue = useCallback(
|
||||
(name: string, type: WidgetInputType, value: WidgetInputValue) => {
|
||||
setInputState((current) => {
|
||||
const existing = current.types[name];
|
||||
if (existing && existing !== type) return current;
|
||||
if (existing === type && Object.is(current.values[name], value)) return current;
|
||||
return {
|
||||
values: { ...current.values, [name]: value },
|
||||
types: existing === type ? current.types : { ...current.types, [name]: type },
|
||||
};
|
||||
});
|
||||
},
|
||||
[setInputState],
|
||||
);
|
||||
const resetInput = useCallback(
|
||||
(name: string, type: WidgetInputType) => {
|
||||
let activeRegistration: InputRegistration | undefined;
|
||||
for (const registration of registrations.current.values()) {
|
||||
if (registration.name !== name) continue;
|
||||
activeRegistration = registration;
|
||||
break;
|
||||
}
|
||||
return { ...current, values: { ...current.values, [name]: activeRegistration.initialValue } };
|
||||
});
|
||||
}, []);
|
||||
if (!activeRegistration || activeRegistration.type !== type) return;
|
||||
setInputState((current) => {
|
||||
if (current.types[name] !== type || Object.is(current.values[name], activeRegistration.initialValue)) {
|
||||
return current;
|
||||
}
|
||||
return { ...current, values: { ...current.values, [name]: activeRegistration.initialValue } };
|
||||
});
|
||||
},
|
||||
[setInputState],
|
||||
);
|
||||
const rendered = useMemo(() => {
|
||||
try {
|
||||
const bindings = { ...createBindings(data), status, options, inputs };
|
||||
const bindings = { ...createBindings(data), status, options, inputs, host };
|
||||
return {
|
||||
...renderSafeJsx({ template, components, bindings }),
|
||||
boundaryKey: createBoundaryKey(template, bindings),
|
||||
@@ -201,7 +214,7 @@ function CustomJsxRendererSession({
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
};
|
||||
}
|
||||
}, [components, createBindings, data, inputs, options, status, template]);
|
||||
}, [components, createBindings, data, host, inputs, options, status, template]);
|
||||
useEffect(() => setParseErrors([]), [rendered.boundaryKey, template]);
|
||||
const handleError = useCallback(
|
||||
(error: Error) =>
|
||||
|
||||
@@ -4,7 +4,7 @@ export { SubData } from "./data";
|
||||
export { RefreshButton } from "./refresh-button";
|
||||
export { SubFetch } from "./sub-fetch";
|
||||
export { CustomJsxRenderer, CUSTOM_JSX_METHOD_COLORS, parseRequestCapabilities } from "./custom-jsx-renderer";
|
||||
export type { CustomJsxRendererMessages, CustomJsxRendererProps } from "./custom-jsx-renderer";
|
||||
export type { CustomJsxRendererMessages, CustomJsxRendererProps, CustomJsxInputState } from "./custom-jsx-renderer";
|
||||
export type { ActionButtonProps, ToggleSwitchProps } from "./actions";
|
||||
export type { SubDataProps } from "./data";
|
||||
export type { RefreshButtonProps } from "./refresh-button";
|
||||
|
||||
@@ -32,7 +32,6 @@ const RESERVED_HEADERS = new Set([
|
||||
const blocked = new BlockList();
|
||||
for (const [address, prefix] of [
|
||||
["0.0.0.0", 8],
|
||||
["100.64.0.0", 10],
|
||||
["169.254.0.0", 16],
|
||||
["192.0.0.0", 24],
|
||||
["192.0.2.0", 24],
|
||||
@@ -56,6 +55,8 @@ for (const [address, prefix] of [
|
||||
blocked.addAddress("fd00:ec2::254", "ipv6");
|
||||
|
||||
const privateAddresses = new BlockList();
|
||||
// Carrier-grade NAT includes Tailscale's IPv4 addresses; access still requires a private source.
|
||||
privateAddresses.addSubnet("100.64.0.0", 10, "ipv4");
|
||||
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
|
||||
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
|
||||
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
customWidgetOptionsSchema,
|
||||
customWidgetRequestsSchema,
|
||||
customWidgetSecretKinds,
|
||||
customWidgetSourcesSchema,
|
||||
} from "../core";
|
||||
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
|
||||
|
||||
const encryptedSecretSchema = z.object({
|
||||
sourceId: z.string(),
|
||||
kind: z.enum(customWidgetSecretKinds),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const sessionSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
revision: z.number().int().nonnegative(),
|
||||
expiresAt: z.number(),
|
||||
sources: customWidgetSourcesSchema,
|
||||
secrets: z.array(encryptedSecretSchema),
|
||||
requests: customWidgetRequestsSchema,
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
iconUrl: z.string().optional(),
|
||||
template: z.string(),
|
||||
optionDefinitions: customWidgetOptionsSchema,
|
||||
options: z.record(z.string(), z.unknown()),
|
||||
definitionId: z.string().optional(),
|
||||
liveActions: z.boolean(),
|
||||
});
|
||||
export type CustomWidgetPreviewSession = z.infer<typeof sessionSchema>;
|
||||
|
||||
export interface CreatePreviewSessionInput {
|
||||
userId: string;
|
||||
sources: Record<string, CustomWidgetSource>;
|
||||
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
|
||||
requests: Record<string, CustomJsxRequest>;
|
||||
name: string;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
template: string;
|
||||
optionDefinitions: CustomWidgetOptions;
|
||||
options: Record<string, unknown>;
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
export interface PreviewSessionStore {
|
||||
saveSession(id: string, value: unknown, ttlMs: number): Promise<void>;
|
||||
compareAndSwapSession(id: string, expectedRevision: number, value: unknown, ttlMs: number): Promise<boolean>;
|
||||
getSession(id: string): Promise<unknown>;
|
||||
deleteSession(id: string): Promise<void>;
|
||||
appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number): Promise<void>;
|
||||
getJournal(id: string, maxEntries: number): Promise<unknown[]>;
|
||||
saveEvidence(id: string, key: string, value: unknown, ttlMs: number): Promise<void>;
|
||||
getEvidence(id: string): Promise<unknown[]>;
|
||||
}
|
||||
|
||||
export interface PreviewSessionServiceOptions {
|
||||
createId(): string;
|
||||
encrypt(value: string): string;
|
||||
decrypt(value: string): string;
|
||||
now?: () => number;
|
||||
store?: PreviewSessionStore;
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
customWidgetOptionsSchema,
|
||||
customWidgetRequestsSchema,
|
||||
customWidgetSecretKinds,
|
||||
customWidgetSourcesSchema,
|
||||
hasSameCustomWidgetSourceAuthentication,
|
||||
} from "../core";
|
||||
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
|
||||
import { customWidgetSecretKinds, hasSameCustomWidgetSourceAuthentication } from "../core";
|
||||
import type { CustomWidgetSource } from "../core";
|
||||
import { sessionSchema } from "./preview-session-schema";
|
||||
import type {
|
||||
CustomWidgetPreviewSession,
|
||||
CreatePreviewSessionInput,
|
||||
PreviewSessionServiceOptions,
|
||||
} from "./preview-session-schema";
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
import {
|
||||
getPreviewEvidenceKey,
|
||||
@@ -23,65 +21,13 @@ const SESSION_TTL_MS = 10 * 60_000;
|
||||
const MAX_JOURNAL_ENTRIES = 50;
|
||||
const MAX_UPDATE_ATTEMPTS = 8;
|
||||
|
||||
const encryptedSecretSchema = z.object({
|
||||
sourceId: z.string(),
|
||||
kind: z.enum(customWidgetSecretKinds),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const sessionSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
revision: z.number().int().nonnegative(),
|
||||
expiresAt: z.number(),
|
||||
sources: customWidgetSourcesSchema,
|
||||
secrets: z.array(encryptedSecretSchema),
|
||||
requests: customWidgetRequestsSchema,
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
iconUrl: z.string().optional(),
|
||||
template: z.string(),
|
||||
optionDefinitions: customWidgetOptionsSchema,
|
||||
options: z.record(z.string(), z.unknown()),
|
||||
definitionId: z.string().optional(),
|
||||
liveActions: z.boolean(),
|
||||
});
|
||||
export type CustomWidgetPreviewSession = z.infer<typeof sessionSchema>;
|
||||
|
||||
export type { CustomWidgetPreviewJournalEntry } from "./preview-session-evidence";
|
||||
|
||||
export interface CreatePreviewSessionInput {
|
||||
userId: string;
|
||||
sources: Record<string, CustomWidgetSource>;
|
||||
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
|
||||
requests: Record<string, CustomJsxRequest>;
|
||||
name: string;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
template: string;
|
||||
optionDefinitions: CustomWidgetOptions;
|
||||
options: Record<string, unknown>;
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
export interface PreviewSessionStore {
|
||||
saveSession(id: string, value: unknown, ttlMs: number): Promise<void>;
|
||||
compareAndSwapSession(id: string, expectedRevision: number, value: unknown, ttlMs: number): Promise<boolean>;
|
||||
getSession(id: string): Promise<unknown>;
|
||||
deleteSession(id: string): Promise<void>;
|
||||
appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number): Promise<void>;
|
||||
getJournal(id: string, maxEntries: number): Promise<unknown[]>;
|
||||
saveEvidence(id: string, key: string, value: unknown, ttlMs: number): Promise<void>;
|
||||
getEvidence(id: string): Promise<unknown[]>;
|
||||
}
|
||||
|
||||
export interface PreviewSessionServiceOptions {
|
||||
createId(): string;
|
||||
encrypt(value: string): string;
|
||||
decrypt(value: string): string;
|
||||
now?: () => number;
|
||||
store?: PreviewSessionStore;
|
||||
}
|
||||
export type {
|
||||
CustomWidgetPreviewSession,
|
||||
CreatePreviewSessionInput,
|
||||
PreviewSessionStore,
|
||||
PreviewSessionServiceOptions,
|
||||
} from "./preview-session-schema";
|
||||
|
||||
export class CustomWidgetPreviewSessionService {
|
||||
private readonly sessions = new Map<string, CustomWidgetPreviewSession>();
|
||||
@@ -140,6 +86,19 @@ export class CustomWidgetPreviewSessionService {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
public async discard(id: string, userId: string) {
|
||||
try {
|
||||
await this.get(id, userId);
|
||||
} catch (error) {
|
||||
if (error instanceof CustomWidgetDomainError && error.code === "NOT_FOUND") return;
|
||||
throw error;
|
||||
}
|
||||
if (this.options.store) await this.options.store.deleteSession(id);
|
||||
this.sessions.delete(id);
|
||||
this.journals.delete(id);
|
||||
this.evidence.delete(id);
|
||||
}
|
||||
|
||||
public async setLiveActions(id: string, userId: string, enabled: boolean) {
|
||||
const session = await this.update(id, userId, (current) => ({ ...current, liveActions: enabled }));
|
||||
return { id, expiresAt: session.expiresAt, liveActions: enabled };
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface CustomWidgetAuthConfig {
|
||||
}
|
||||
|
||||
export interface CustomWidgetHttpRequest {
|
||||
signal?: AbortSignal;
|
||||
baseUrl: string;
|
||||
targetUrl?: string | URL;
|
||||
method: CustomWidgetMethod;
|
||||
@@ -75,8 +76,11 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS);
|
||||
try {
|
||||
return await performRequestWithinDeadline(input, controller.signal);
|
||||
input.signal?.throwIfAborted();
|
||||
const signal = input.signal ? AbortSignal.any([input.signal, controller.signal]) : controller.signal;
|
||||
return await performRequestWithinDeadline(input, signal);
|
||||
} catch (error) {
|
||||
if (input.signal?.aborted) throw input.signal.reason;
|
||||
if (controller.signal.aborted) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_GATEWAY",
|
||||
|
||||
@@ -41,21 +41,21 @@ export function CodeEditorToolbar({
|
||||
onFormat,
|
||||
}: CodeEditorToolbarProps) {
|
||||
const starter = props.starter;
|
||||
let languageLabel = props.messages.languageJson;
|
||||
if (props.language === "jsx") languageLabel = props.messages.languageJsx;
|
||||
if (props.language === "tsx") languageLabel = "TSX";
|
||||
if (props.language === "css") languageLabel = "CSS";
|
||||
return (
|
||||
<Group className={classes.toolbar} justify="space-between" gap="xs" wrap="wrap">
|
||||
<Group gap={6}>
|
||||
<IconCode size={16} aria-hidden />
|
||||
<Text size="xs" fw={600}>
|
||||
{props.language === "jsx"
|
||||
? props.messages.languageJsx
|
||||
: props.language === "css"
|
||||
? "CSS"
|
||||
: props.messages.languageJson}
|
||||
{languageLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
{props.actions}
|
||||
{!props.readOnly && (
|
||||
{!props.readOnly && !props.hideHistoryActions && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -109,7 +109,7 @@ export function CodeEditorToolbar({
|
||||
variant="subtle"
|
||||
leftSection={<IconWand size={14} />}
|
||||
onClick={onFormat}
|
||||
disabled={props.language !== "jsx" && formattedValue === props.value}
|
||||
disabled={!props.onFormat && props.language !== "jsx" && formattedValue === props.value}
|
||||
>
|
||||
{props.messages.format}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Completion } from "@codemirror/autocomplete";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { EditorDiagnostic } from "./analyzer";
|
||||
@@ -43,7 +44,7 @@ export interface CustomWidgetCodeEditorProps {
|
||||
messages: CustomWidgetEditorMessages;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
language: "jsx" | "json" | "css";
|
||||
language: "jsx" | "tsx" | "json" | "css";
|
||||
diagnostics?: EditorDiagnostic[];
|
||||
error?: ReactNode;
|
||||
required?: boolean;
|
||||
@@ -51,11 +52,16 @@ export interface CustomWidgetCodeEditorProps {
|
||||
starter?: string;
|
||||
completions?: Completion[];
|
||||
revealText?: string;
|
||||
revealRange?: { from: number; to: number };
|
||||
revealKey?: number;
|
||||
insertText?: string;
|
||||
insertKey?: number;
|
||||
readOnly?: boolean;
|
||||
hideHistoryActions?: boolean;
|
||||
reference?: CustomWidgetSchemaReferenceData;
|
||||
height?: string;
|
||||
actions?: ReactNode;
|
||||
status?: ReactNode;
|
||||
extensions?: Extension;
|
||||
onFormat?(): void;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import { EditorDiagnostics } from "./code-editor-diagnostics";
|
||||
import { CodeEditorToolbar } from "./code-editor-toolbar";
|
||||
import { formatCode } from "./code-format";
|
||||
import type { EditorDiagnostic } from "./analyzer";
|
||||
import type {
|
||||
CustomWidgetCodeEditorProps,
|
||||
@@ -42,15 +43,18 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const editorView = editorViewRef.current;
|
||||
if (!editorView || !props.revealText) return;
|
||||
const from = editorView.state.doc.toString().indexOf(props.revealText);
|
||||
if (!editorView || (!props.revealText && !props.revealRange)) return;
|
||||
const from = props.revealRange?.from ?? editorView.state.doc.toString().indexOf(props.revealText ?? "");
|
||||
if (from < 0) return;
|
||||
editorView.dispatch({
|
||||
selection: { anchor: from, head: from + props.revealText.length },
|
||||
selection: {
|
||||
anchor: Math.min(from, editorView.state.doc.length),
|
||||
head: Math.min(props.revealRange?.to ?? from + (props.revealText?.length ?? 0), editorView.state.doc.length),
|
||||
},
|
||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||
});
|
||||
editorView.focus();
|
||||
}, [editorCreated, props.revealKey, props.revealText]);
|
||||
}, [editorCreated, props.revealKey, props.revealText, props.revealRange]);
|
||||
useEffect(() => {
|
||||
const editorView = editorViewRef.current;
|
||||
if (!editorView || !props.insertText) return;
|
||||
@@ -138,6 +142,10 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onFormat={() => {
|
||||
if (props.onFormat) {
|
||||
props.onFormat();
|
||||
return;
|
||||
}
|
||||
const editorView = editorViewRef.current;
|
||||
if (props.language === "jsx" && editorView) {
|
||||
const selection = editorView.state.selection.main;
|
||||
@@ -174,6 +182,7 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
language={props.language}
|
||||
diagnostics={diagnostics}
|
||||
completions={props.completions ?? EMPTY_COMPLETIONS}
|
||||
extensions={props.extensions}
|
||||
label={props.label}
|
||||
placeholder={props.placeholder}
|
||||
theme={colorScheme}
|
||||
@@ -193,22 +202,23 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
</div>
|
||||
<Group className={classes.footer} justify="space-between" gap="xs">
|
||||
<Group gap={6}>
|
||||
{errorCount > 0 ? (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
{props.messages.errors(errorCount)}
|
||||
</Badge>
|
||||
) : warningCount > 0 ? (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
{props.messages.warnings(warningCount)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Group gap={4}>
|
||||
<IconCheck size={13} color="var(--mantine-color-green-6)" aria-hidden />
|
||||
<Text size="xs" className={classes.accessibleMuted}>
|
||||
{props.messages.ready}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{props.status ??
|
||||
(errorCount > 0 ? (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
{props.messages.errors(errorCount)}
|
||||
</Badge>
|
||||
) : warningCount > 0 ? (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
{props.messages.warnings(warningCount)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Group gap={4}>
|
||||
<IconCheck size={13} color="var(--mantine-color-green-6)" aria-hidden />
|
||||
<Text size="xs" className={classes.accessibleMuted}>
|
||||
{props.messages.ready}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Text size="xs" className={classes.accessibleMuted}>
|
||||
{props.messages.position(cursor)}
|
||||
</Text>
|
||||
@@ -274,19 +284,3 @@ function EditorLoader() {
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
function formatCode(value: string, language: "jsx" | "json" | "css") {
|
||||
if (language === "json") {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return `${JSON.stringify(parsed, null, 2)}\n`;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value
|
||||
.replace(/\r\n?/gu, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function formatCode(value: string, language: "jsx" | "tsx" | "json" | "css") {
|
||||
if (language === "json") {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return `${JSON.stringify(parsed, null, 2)}\n`;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value
|
||||
.replace(/\r\n?/gu, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
@@ -20,9 +20,10 @@ import type { EditorDiagnostic } from "./analyzer";
|
||||
|
||||
interface DirectCodeMirrorProps {
|
||||
value: string;
|
||||
language: "jsx" | "json" | "css";
|
||||
language: "jsx" | "tsx" | "json" | "css";
|
||||
diagnostics: EditorDiagnostic[];
|
||||
completions: Completion[];
|
||||
extensions?: Extension;
|
||||
id: string;
|
||||
label: string;
|
||||
labelledBy: string;
|
||||
@@ -51,6 +52,7 @@ interface EditorCompartments {
|
||||
placeholder: Compartment;
|
||||
height: Compartment;
|
||||
theme: Compartment;
|
||||
additional: Compartment;
|
||||
}
|
||||
|
||||
const editorViews = new Map<string, EditorView>();
|
||||
@@ -141,6 +143,9 @@ export default function DirectCodeMirror(props: DirectCodeMirrorProps) {
|
||||
useEffect(() => {
|
||||
reconfigure(instanceId, compartments.theme, createThemeExtension(props.theme));
|
||||
}, [compartments.theme, instanceId, props.theme]);
|
||||
useEffect(() => {
|
||||
reconfigure(instanceId, compartments.additional, props.extensions ?? []);
|
||||
}, [compartments.additional, instanceId, props.extensions]);
|
||||
|
||||
return <div ref={containerRef} />;
|
||||
}
|
||||
@@ -154,6 +159,7 @@ function createEditorCompartments(): EditorCompartments {
|
||||
placeholder: new Compartment(),
|
||||
height: new Compartment(),
|
||||
theme: new Compartment(),
|
||||
additional: new Compartment(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,6 +181,7 @@ function createExtensions(instanceId: string, props: DirectCodeMirrorProps, comp
|
||||
compartments.placeholder.of(createPlaceholderExtension(props.placeholder)),
|
||||
compartments.height.of(createHeightExtension(props.height)),
|
||||
compartments.theme.of(createThemeExtension(props.theme)),
|
||||
compartments.additional.of(props.extensions ?? []),
|
||||
EditorView.updateListener.of((update) => {
|
||||
const callbacks = editorCallbacks.get(instanceId);
|
||||
if (!callbacks) return;
|
||||
@@ -195,6 +202,7 @@ function createLanguageExtension(language: DirectCodeMirrorProps["language"], co
|
||||
customJsxComponentHover,
|
||||
];
|
||||
}
|
||||
if (language === "tsx") return javascript({ jsx: true, typescript: true });
|
||||
if (language === "css") return css();
|
||||
return json();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { CUSTOM_WIDGET_STARTER } from "../core";
|
||||
import { CUSTOM_WIDGET_STATIC_STARTER } from "../core";
|
||||
|
||||
const jsonObjectString = z.string().superRefine((value, ctx) => {
|
||||
try {
|
||||
@@ -40,12 +40,12 @@ export const CUSTOM_WIDGET_AUTH_SECRET_FIELDS: Record<
|
||||
};
|
||||
|
||||
export const DEFAULT_CUSTOM_WIDGET_FORM_VALUES: CustomWidgetFormValues = {
|
||||
name: CUSTOM_WIDGET_STARTER.name,
|
||||
description: CUSTOM_WIDGET_STARTER.description ?? "",
|
||||
iconUrl: CUSTOM_WIDGET_STARTER.iconUrl ?? "",
|
||||
sources: JSON.stringify(CUSTOM_WIDGET_STARTER.sources, null, 2),
|
||||
requests: JSON.stringify(CUSTOM_WIDGET_STARTER.requests, null, 2),
|
||||
options: JSON.stringify(CUSTOM_WIDGET_STARTER.options, null, 2),
|
||||
template: CUSTOM_WIDGET_STARTER.template,
|
||||
name: CUSTOM_WIDGET_STATIC_STARTER.name,
|
||||
description: CUSTOM_WIDGET_STATIC_STARTER.description ?? "",
|
||||
iconUrl: CUSTOM_WIDGET_STATIC_STARTER.iconUrl ?? "",
|
||||
sources: JSON.stringify(CUSTOM_WIDGET_STATIC_STARTER.sources, null, 2),
|
||||
requests: JSON.stringify(CUSTOM_WIDGET_STATIC_STARTER.requests, null, 2),
|
||||
options: JSON.stringify(CUSTOM_WIDGET_STATIC_STARTER.options, null, 2),
|
||||
template: CUSTOM_WIDGET_STATIC_STARTER.template,
|
||||
secrets: [],
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { PackageLanguageMethod, PackageLanguageMethods, PackageLanguageResponse } from "./types";
|
||||
|
||||
interface PendingRequest {
|
||||
revision: number;
|
||||
resolve(value: unknown): void;
|
||||
reject(error: Error): void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class PackageLanguageClient {
|
||||
private readonly worker: Worker;
|
||||
private readonly pending = new Map<number, PendingRequest>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private files: Record<string, string> = {};
|
||||
private id = 0;
|
||||
private revision = 0;
|
||||
private disposed = false;
|
||||
private failure: Error | undefined;
|
||||
private queuedChanges: Record<string, string | null> = {};
|
||||
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
constructor() {
|
||||
this.worker = new Worker("/__widget-editor/worker.js", { type: "module", name: "homarr-widget-typescript" });
|
||||
this.worker.addEventListener("message", (event: MessageEvent<PackageLanguageResponse>) => {
|
||||
const response = event.data;
|
||||
const pending = this.pending.get(response.id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(response.id);
|
||||
clearTimeout(pending.timer);
|
||||
if (pending.revision !== this.revision || response.revision !== pending.revision) {
|
||||
pending.reject(new DOMException("The editor document changed", "AbortError"));
|
||||
return;
|
||||
}
|
||||
if ("error" in response) pending.reject(new Error(response.error));
|
||||
else pending.resolve(response.value);
|
||||
});
|
||||
this.worker.addEventListener("error", () => {
|
||||
this.failure = new Error(
|
||||
"The local TypeScript worker could not start. Reopen the editor after rebuilding its assets.",
|
||||
);
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(this.failure);
|
||||
}
|
||||
this.pending.clear();
|
||||
});
|
||||
}
|
||||
|
||||
update(files: Record<string, string>) {
|
||||
if (this.disposed) return;
|
||||
let changed = false;
|
||||
for (const path of new Set([...Object.keys(files), ...Object.keys(this.files)])) {
|
||||
if (files[path] === this.files[path]) continue;
|
||||
this.queuedChanges[path] = files[path] ?? null;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return;
|
||||
this.files = files;
|
||||
this.revision += 1;
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = setTimeout(() => this.flush(), 120);
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
private flush() {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = undefined;
|
||||
if (!Object.keys(this.queuedChanges).length || this.disposed) return;
|
||||
this.worker.postMessage({ method: "update", revision: this.revision, changes: this.queuedChanges }, []);
|
||||
this.queuedChanges = {};
|
||||
}
|
||||
|
||||
updateActive(path: string, source: string) {
|
||||
if (this.files[path] !== source) this.update({ ...this.files, [path]: source });
|
||||
}
|
||||
|
||||
getRevision() {
|
||||
return this.revision;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void) {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
request<K extends PackageLanguageMethod>(
|
||||
method: K,
|
||||
input: PackageLanguageMethods[K]["input"],
|
||||
): Promise<PackageLanguageMethods[K]["output"]> {
|
||||
if (this.disposed) return Promise.reject(new DOMException("The editor closed", "AbortError"));
|
||||
if (this.failure) return Promise.reject(this.failure);
|
||||
this.flush();
|
||||
const id = ++this.id;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error("The TypeScript language service did not respond in time"));
|
||||
}, 30_000);
|
||||
this.pending.set(id, {
|
||||
revision: this.revision,
|
||||
resolve: (value) => resolve(value as PackageLanguageMethods[K]["output"]),
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
this.worker.postMessage({ id, revision: this.revision, method, input }, []);
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
clearTimeout(this.flushTimer);
|
||||
this.worker.terminate();
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new DOMException("The editor closed", "AbortError"));
|
||||
}
|
||||
this.pending.clear();
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import type { Completion } from "@codemirror/autocomplete";
|
||||
import { forceLinting, linter } from "@codemirror/lint";
|
||||
import { Prec } from "@codemirror/state";
|
||||
import { hoverTooltip, keymap, ViewPlugin } from "@codemirror/view";
|
||||
|
||||
import type { PackageLanguageClient } from "./client";
|
||||
import type { PackageLanguageCallbacks, PackageLanguageInfo } from "./types";
|
||||
|
||||
export function createPackageLanguageExtensions(
|
||||
client: PackageLanguageClient,
|
||||
path: string,
|
||||
callbacks: PackageLanguageCallbacks,
|
||||
) {
|
||||
const report = (cause: unknown) => {
|
||||
if (cause instanceof Error && cause.name === "AbortError") return;
|
||||
callbacks.error(cause instanceof Error ? cause.message : String(cause));
|
||||
};
|
||||
return [
|
||||
ViewPlugin.define((view) => {
|
||||
let active = true;
|
||||
const unsubscribe = client.subscribe(() => {
|
||||
// A change in another file also invalidates this file's semantic diagnostics.
|
||||
queueMicrotask(() => {
|
||||
if (active) forceLinting(view);
|
||||
});
|
||||
});
|
||||
return {
|
||||
destroy() {
|
||||
active = false;
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
}),
|
||||
linter(
|
||||
async (view) => {
|
||||
const source = view.state.doc.toString();
|
||||
client.updateActive(path, source);
|
||||
try {
|
||||
const diagnostics = await client.request("diagnostics", { path });
|
||||
if (view.state.doc.toString() !== source) return [];
|
||||
callbacks.diagnostics(diagnostics);
|
||||
return diagnostics.map((diagnostic) => ({
|
||||
from: Math.min(diagnostic.from, source.length),
|
||||
to: Math.min(diagnostic.to, source.length),
|
||||
severity: diagnostic.severity,
|
||||
message: diagnostic.message,
|
||||
source: `TS${diagnostic.code}`,
|
||||
}));
|
||||
} catch (cause) {
|
||||
report(cause);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
{ delay: 450 },
|
||||
),
|
||||
autocompletion({
|
||||
override: [
|
||||
async (context) => {
|
||||
const word = context.matchBefore(/[\w$]+/u);
|
||||
if (!context.explicit && !word && context.state.sliceDoc(Math.max(0, context.pos - 1), context.pos) !== ".")
|
||||
return null;
|
||||
client.updateActive(path, context.state.doc.toString());
|
||||
try {
|
||||
const completions = await client.request("complete", {
|
||||
path,
|
||||
position: context.pos,
|
||||
prefix: word?.text ?? "",
|
||||
});
|
||||
if (context.aborted) return null;
|
||||
return {
|
||||
from: word?.from ?? context.pos,
|
||||
options: completions.map(
|
||||
(completion): Completion => ({
|
||||
label: completion.name,
|
||||
type: completionKind(completion.kind),
|
||||
detail: completion.kind,
|
||||
apply: (view, _completion, from, to) => {
|
||||
const start = completion.from ?? from;
|
||||
const insert = completion.insertText ?? completion.name;
|
||||
view.dispatch({
|
||||
changes: { from: start, to: completion.to ?? to, insert },
|
||||
selection: { anchor: start + insert.length },
|
||||
});
|
||||
},
|
||||
info: async () => {
|
||||
try {
|
||||
const info = await client.request("detail", {
|
||||
path,
|
||||
position: context.pos,
|
||||
name: completion.name,
|
||||
});
|
||||
return infoNode(info);
|
||||
} catch {
|
||||
return infoNode(null);
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
};
|
||||
} catch (cause) {
|
||||
report(cause);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
],
|
||||
}),
|
||||
hoverTooltip(async (view, position) => {
|
||||
const source = view.state.doc.toString();
|
||||
client.updateActive(path, source);
|
||||
try {
|
||||
const info = await client.request("hover", { path, position });
|
||||
if (!info || view.state.doc.toString() !== source) return null;
|
||||
return { pos: info.from, end: info.to, above: true, create: () => ({ dom: infoNode(info) }) };
|
||||
} catch (cause) {
|
||||
report(cause);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
Prec.high(
|
||||
keymap.of([
|
||||
{
|
||||
key: "F12",
|
||||
run(view) {
|
||||
client.updateActive(path, view.state.doc.toString());
|
||||
void client
|
||||
.request("definition", { path, position: view.state.selection.main.head })
|
||||
.then((location) => {
|
||||
if (location) callbacks.navigate(location);
|
||||
})
|
||||
.catch(report);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "F2",
|
||||
run(view) {
|
||||
if (!callbacks.rename || view.state.readOnly) return false;
|
||||
const position = view.state.selection.main.head;
|
||||
const word = view.state.wordAt(position);
|
||||
if (!word) return false;
|
||||
callbacks.rename(position, view.state.sliceDoc(word.from, word.to));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function completionKind(kind: string) {
|
||||
if (["method", "function", "getter", "setter"].includes(kind)) return "function";
|
||||
if (["class", "interface", "type", "enum", "module"].includes(kind)) return "class";
|
||||
if (["property", "member variable"].includes(kind)) return "property";
|
||||
if (kind === "keyword") return "keyword";
|
||||
return "variable";
|
||||
}
|
||||
|
||||
function infoNode(info: PackageLanguageInfo | null) {
|
||||
const node = document.createElement("div");
|
||||
node.style.cssText = "white-space:pre-wrap;padding:8px;max-width:min(560px,80vw);max-height:260px;overflow:auto";
|
||||
if (info) node.textContent = [info.signature, info.documentation].filter(Boolean).join("\n\n");
|
||||
return node;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { PackageLanguageClient } from "./client";
|
||||
export { createPackageLanguageExtensions } from "./extensions";
|
||||
export type { PackageLanguageDiagnostic, PackageLanguageLocation } from "./types";
|
||||
@@ -0,0 +1,98 @@
|
||||
import ts from "typescript-compiler-api";
|
||||
|
||||
import type { PackageLanguageCompletion, PackageLanguageInfo, PackageLanguageLocation } from "./types";
|
||||
|
||||
export function languageCompletion(
|
||||
service: ts.LanguageService,
|
||||
file: string,
|
||||
position: number,
|
||||
prefix: string,
|
||||
): PackageLanguageCompletion[] {
|
||||
const result = service.getCompletionsAtPosition(file, position, {
|
||||
includeCompletionsForModuleExports: false,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
return (result?.entries ?? [])
|
||||
.filter((entry) => !prefix || entry.name.toLowerCase().includes(prefix.toLowerCase()))
|
||||
.slice(0, 500)
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
kind: entry.kind,
|
||||
sortText: entry.sortText,
|
||||
insertText: entry.insertText,
|
||||
from: entry.replacementSpan?.start,
|
||||
to: entry.replacementSpan && entry.replacementSpan.start + entry.replacementSpan.length,
|
||||
}));
|
||||
}
|
||||
|
||||
export function languageInfo(service: ts.LanguageService, file: string, position: number): PackageLanguageInfo | null {
|
||||
const info = service.getQuickInfoAtPosition(file, position);
|
||||
if (!info) return null;
|
||||
return {
|
||||
from: info.textSpan.start,
|
||||
to: info.textSpan.start + info.textSpan.length,
|
||||
signature: ts.displayPartsToString(info.displayParts),
|
||||
documentation: ts.displayPartsToString(info.documentation),
|
||||
};
|
||||
}
|
||||
|
||||
export function languageDefinition(
|
||||
service: ts.LanguageService,
|
||||
file: string,
|
||||
position: number,
|
||||
readFile: (file: string) => string | undefined,
|
||||
): PackageLanguageLocation | null {
|
||||
const definitions = service.getDefinitionAtPosition(file, position);
|
||||
const definition = definitions?.find((item) => item.fileName.startsWith("/widget/")) ?? definitions?.[0];
|
||||
if (!definition) return null;
|
||||
const { textSpan, fileName } = definition;
|
||||
if (fileName.startsWith("/widget/"))
|
||||
return { path: fileName.slice("/widget/".length), from: textSpan.start, to: textSpan.start + textSpan.length };
|
||||
const source = readFile(fileName);
|
||||
if (!source) return null;
|
||||
// Some installed declaration files contain thousands of icons. Show a bounded excerpt.
|
||||
let from = Math.max(0, textSpan.start - 2000);
|
||||
if (from > 0) from = source.indexOf("\n", from) + 1;
|
||||
const end = source.indexOf("\n", Math.min(source.length - 1, textSpan.start + textSpan.length + 2000));
|
||||
let to = source.length;
|
||||
if (end >= 0) to = end;
|
||||
return {
|
||||
path: fileName,
|
||||
from: textSpan.start - from,
|
||||
to: textSpan.start + textSpan.length - from,
|
||||
declaration: { source: source.slice(from, to), line: source.slice(0, from).split("\n").length },
|
||||
};
|
||||
}
|
||||
|
||||
export function renameLanguageSymbol(
|
||||
service: ts.LanguageService,
|
||||
file: string,
|
||||
position: number,
|
||||
name: string,
|
||||
project: Record<string, string>,
|
||||
) {
|
||||
const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, ts.LanguageVariant.Standard, name);
|
||||
if (scanner.scan() !== ts.SyntaxKind.Identifier || scanner.scan() !== ts.SyntaxKind.EndOfFileToken)
|
||||
throw new Error("Enter a JavaScript identifier for the new name");
|
||||
const info = service.getRenameInfo(file, position, { allowRenameOfImportPath: false });
|
||||
if (!info.canRename) throw new Error(info.localizedErrorMessage);
|
||||
const locations =
|
||||
service.findRenameLocations(file, position, false, false, { providePrefixAndSuffixTextForRename: true }) ?? [];
|
||||
if (locations.some((location) => !Object.hasOwn(project, location.fileName)))
|
||||
throw new Error("Installed SDK and dependency declarations are read-only");
|
||||
const changed: Record<string, string> = {};
|
||||
for (const path of new Set(locations.map((location) => location.fileName))) {
|
||||
let source = project[path] ?? "";
|
||||
for (const location of locations
|
||||
.filter((item) => item.fileName === path)
|
||||
.toSorted((a, b) => b.textSpan.start - a.textSpan.start)) {
|
||||
const replacement = `${location.prefixText ?? ""}${name}${location.suffixText ?? ""}`;
|
||||
source =
|
||||
source.slice(0, location.textSpan.start) +
|
||||
replacement +
|
||||
source.slice(location.textSpan.start + location.textSpan.length);
|
||||
}
|
||||
changed[path.slice("/widget/".length)] = source;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import ts from "typescript-compiler-api";
|
||||
|
||||
import { languageCompletion, languageDefinition, languageInfo, renameLanguageSymbol } from "./service-operations";
|
||||
import type { PackageLanguageLibraries, PackageLanguageRequest } from "./types";
|
||||
|
||||
/** Pure in-memory language service. Package code is parsed, never executed. */
|
||||
export function createPackageLanguageService(libraries: PackageLanguageLibraries) {
|
||||
const project: Record<string, string> = {};
|
||||
let projectVersion = 0;
|
||||
const versions = new Map<string, number>();
|
||||
const snapshots = new Map<string, ts.IScriptSnapshot>();
|
||||
const directories = new Set<string>();
|
||||
const options: ts.CompilerOptions = {
|
||||
module: ts.ModuleKind.ESNext,
|
||||
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||||
target: ts.ScriptTarget.ESNext,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
strict: true,
|
||||
skipLibCheck: true,
|
||||
noUncheckedIndexedAccess: true,
|
||||
allowJs: true,
|
||||
checkJs: false,
|
||||
noEmit: true,
|
||||
allowImportingTsExtensions: true,
|
||||
resolveJsonModule: true,
|
||||
allowArbitraryExtensions: true,
|
||||
types: [],
|
||||
};
|
||||
const readFile = (file: string) => project[file] ?? libraries.files[file];
|
||||
const host: ts.LanguageServiceHost = {
|
||||
getScriptFileNames: () => [
|
||||
...Object.keys(project).filter((file) => /\.[cm]?[jt]sx?$/u.test(file)),
|
||||
...libraries.globalFiles,
|
||||
],
|
||||
getScriptVersion: (file) => String(versions.get(file) ?? 0),
|
||||
getProjectVersion: () => String(projectVersion),
|
||||
getScriptSnapshot: (file) => {
|
||||
const source = readFile(file);
|
||||
if (source === undefined) return undefined;
|
||||
let snapshot = snapshots.get(file);
|
||||
if (!snapshot) {
|
||||
snapshot = ts.ScriptSnapshot.fromString(source);
|
||||
snapshots.set(file, snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
},
|
||||
getCurrentDirectory: () => "/widget",
|
||||
getCompilationSettings: () => options,
|
||||
getDefaultLibFileName: () => libraries.defaultLib,
|
||||
fileExists: (file) => readFile(file) !== undefined,
|
||||
readFile,
|
||||
readDirectory: () => [],
|
||||
directoryExists: (directory) => directories.has(directory.replace(/\/$/u, "") || "/"),
|
||||
getDirectories: (directory) =>
|
||||
[...directories].filter(
|
||||
(candidate) => candidate.startsWith(`${directory}/`) && !candidate.slice(directory.length + 1).includes("/"),
|
||||
),
|
||||
useCaseSensitiveFileNames: () => true,
|
||||
resolveModuleNames: (names, from) =>
|
||||
names.map((name) => {
|
||||
const root = libraries.roots[name];
|
||||
if (root) return { resolvedFileName: root, isExternalLibraryImport: true };
|
||||
return ts.resolveModuleName(name, from, options, host).resolvedModule;
|
||||
}),
|
||||
};
|
||||
const updateDirectories = () => {
|
||||
directories.clear();
|
||||
for (const file of [...Object.keys(libraries.files), ...Object.keys(project)]) {
|
||||
let directory = file;
|
||||
while (directory.includes("/")) {
|
||||
directory = directory.slice(0, directory.lastIndexOf("/"));
|
||||
directories.add(directory || "/");
|
||||
}
|
||||
}
|
||||
};
|
||||
updateDirectories();
|
||||
const service = ts.createLanguageService(host);
|
||||
const patch = (changes: Record<string, string | null>) => {
|
||||
let layoutChanged = false;
|
||||
for (const [path, source] of Object.entries(changes)) {
|
||||
const file = `/widget/${path}`;
|
||||
if (source === null) {
|
||||
delete project[file];
|
||||
layoutChanged = true;
|
||||
} else {
|
||||
if (!Object.hasOwn(project, file)) layoutChanged = true;
|
||||
project[file] = source;
|
||||
}
|
||||
versions.set(file, (versions.get(file) ?? 0) + 1);
|
||||
snapshots.delete(file);
|
||||
}
|
||||
projectVersion += 1;
|
||||
if (layoutChanged) updateDirectories();
|
||||
};
|
||||
return {
|
||||
patch,
|
||||
update(files: Record<string, string>) {
|
||||
const changes: Record<string, string | null> = { ...files };
|
||||
for (const file of Object.keys(project)) {
|
||||
const path = file.slice("/widget/".length);
|
||||
if (!Object.hasOwn(files, path)) changes[path] = null;
|
||||
}
|
||||
patch(changes);
|
||||
},
|
||||
execute(request: PackageLanguageRequest): unknown {
|
||||
const file = `/widget/${request.input.path}`;
|
||||
const source = project[file];
|
||||
if (source === undefined) throw new Error("This package file is no longer available");
|
||||
switch (request.method) {
|
||||
case "diagnostics":
|
||||
return [...service.getSyntacticDiagnostics(file), ...service.getSemanticDiagnostics(file)]
|
||||
.slice(0, 100)
|
||||
.map((diagnostic) => ({
|
||||
path: request.input.path,
|
||||
from: diagnostic.start ?? 0,
|
||||
to: (diagnostic.start ?? 0) + (diagnostic.length ?? 0),
|
||||
severity: diagnostic.category === ts.DiagnosticCategory.Error ? "error" : "warning",
|
||||
message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
|
||||
code: diagnostic.code,
|
||||
}));
|
||||
case "complete":
|
||||
return languageCompletion(service, file, request.input.position, request.input.prefix);
|
||||
case "detail": {
|
||||
const details = service.getCompletionEntryDetails(
|
||||
file,
|
||||
request.input.position,
|
||||
request.input.name,
|
||||
{},
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
if (!details) return null;
|
||||
return {
|
||||
from: request.input.position,
|
||||
to: request.input.position,
|
||||
signature: ts.displayPartsToString(details.displayParts),
|
||||
documentation: ts.displayPartsToString(details.documentation),
|
||||
};
|
||||
}
|
||||
case "hover":
|
||||
return languageInfo(service, file, request.input.position);
|
||||
case "definition":
|
||||
return languageDefinition(service, file, request.input.position, readFile);
|
||||
case "format": {
|
||||
const changes = service.getFormattingEditsForDocument(file, {
|
||||
...ts.getDefaultFormatCodeSettings("\n"),
|
||||
indentSize: 2,
|
||||
tabSize: 2,
|
||||
convertTabsToSpaces: true,
|
||||
newLineCharacter: "\n",
|
||||
semicolons: ts.SemicolonPreference.Insert,
|
||||
});
|
||||
return applyLanguageChanges(source, changes);
|
||||
}
|
||||
case "rename":
|
||||
return renameLanguageSymbol(service, file, request.input.position, request.input.name, project);
|
||||
}
|
||||
},
|
||||
dispose: () => service.dispose(),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyLanguageChanges(source: string, changes: readonly ts.TextChange[]) {
|
||||
for (const change of changes.toSorted((left, right) => right.span.start - left.span.start)) {
|
||||
source = source.slice(0, change.span.start) + change.newText + source.slice(change.span.start + change.span.length);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export interface PackageLanguageLibraries {
|
||||
files: Record<string, string>;
|
||||
roots: Record<string, string>;
|
||||
globalFiles: string[];
|
||||
defaultLib: string;
|
||||
sdkVersion: string;
|
||||
typescriptVersion: string;
|
||||
}
|
||||
|
||||
export interface PackageLanguageLocation {
|
||||
path: string;
|
||||
from: number;
|
||||
to: number;
|
||||
/** Declaration excerpts are read-only and never become package files. */
|
||||
declaration?: { source: string; line: number };
|
||||
}
|
||||
|
||||
export interface PackageLanguageDiagnostic extends PackageLanguageLocation {
|
||||
message: string;
|
||||
severity: "error" | "warning" | "info";
|
||||
code: number;
|
||||
}
|
||||
|
||||
export interface PackageLanguageCompletion {
|
||||
name: string;
|
||||
kind: string;
|
||||
sortText: string;
|
||||
insertText?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
}
|
||||
|
||||
export interface PackageLanguageInfo {
|
||||
from: number;
|
||||
to: number;
|
||||
signature: string;
|
||||
documentation: string;
|
||||
}
|
||||
|
||||
export interface PackageLanguageMethods {
|
||||
diagnostics: { input: { path: string }; output: PackageLanguageDiagnostic[] };
|
||||
complete: {
|
||||
input: { path: string; position: number; prefix: string };
|
||||
output: PackageLanguageCompletion[];
|
||||
};
|
||||
detail: { input: { path: string; position: number; name: string }; output: PackageLanguageInfo | null };
|
||||
hover: { input: { path: string; position: number }; output: PackageLanguageInfo | null };
|
||||
definition: { input: { path: string; position: number }; output: PackageLanguageLocation | null };
|
||||
format: { input: { path: string }; output: string };
|
||||
rename: {
|
||||
input: { path: string; position: number; name: string };
|
||||
output: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export type PackageLanguageMethod = keyof PackageLanguageMethods;
|
||||
export type PackageLanguageRequest = {
|
||||
[K in PackageLanguageMethod]: {
|
||||
id: number;
|
||||
revision: number;
|
||||
method: K;
|
||||
input: PackageLanguageMethods[K]["input"];
|
||||
};
|
||||
}[PackageLanguageMethod];
|
||||
export type PackageLanguageMessage =
|
||||
| PackageLanguageRequest
|
||||
| { method: "update"; revision: number; changes: Record<string, string | null> };
|
||||
export type PackageLanguageResponse =
|
||||
| { id: number; value: unknown; revision: number }
|
||||
| { id: number; error: string; revision: number };
|
||||
|
||||
export interface PackageLanguageCallbacks {
|
||||
navigate(location: PackageLanguageLocation): void;
|
||||
diagnostics(diagnostics: PackageLanguageDiagnostic[]): void;
|
||||
error(message: string): void;
|
||||
rename?(position: number, currentName: string): void;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createPackageLanguageService } from "./service";
|
||||
import type { PackageLanguageLibraries, PackageLanguageMessage, PackageLanguageResponse } from "./types";
|
||||
|
||||
const scope = globalThis as unknown as {
|
||||
postMessage(message: PackageLanguageResponse, transfer: Transferable[]): void;
|
||||
addEventListener(type: "message", listener: (event: MessageEvent<PackageLanguageMessage>) => void): void;
|
||||
};
|
||||
const ready = fetch(new URL("./types.json", import.meta.url)).then(async (response) => {
|
||||
if (!response.ok) throw new Error(`Unable to load installed editor types (${response.status})`);
|
||||
const libraries = (await response.json()) as PackageLanguageLibraries;
|
||||
return createPackageLanguageService(libraries);
|
||||
});
|
||||
let revision = 0;
|
||||
void ready.catch(() => undefined);
|
||||
scope.addEventListener("message", async (event) => {
|
||||
const message = event.data;
|
||||
try {
|
||||
const service = await ready;
|
||||
if (message.method === "update") {
|
||||
service.patch(message.changes);
|
||||
revision = message.revision;
|
||||
return;
|
||||
}
|
||||
if (message.revision !== revision) throw new Error("The editor document changed");
|
||||
scope.postMessage({ id: message.id, revision, value: service.execute(message) }, []);
|
||||
} catch (cause) {
|
||||
if (message.method === "update") return;
|
||||
const error = cause instanceof Error ? cause.message : String(cause);
|
||||
scope.postMessage({ id: message.id, revision: message.revision, error }, []);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user