feat: strengthen module contracts and shared WebUI runtime

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent 53e947935a
commit 68328f3d8e
57 changed files with 4358 additions and 371 deletions

View File

@@ -1,4 +1,4 @@
import { createContext, type ReactNode, useContext } from "react";
import { createContext, type ReactNode, useContext, useMemo } from "react";
import type { PlatformWebModule } from "../types";
import { moduleInstalled, uiCapabilities, uiCapability } from "./modules";
import { useEffectiveView, useViewSurfaces } from "./ViewContext";
@@ -19,22 +19,34 @@ export function usePlatformModuleInstalled(moduleId: string): boolean {
}
export function usePlatformUiCapability<T = unknown>(capabilityName: string): T | null {
return uiCapability<T>(capabilityName, useViewVisibleModules());
const modules = useViewVisibleModules();
return useMemo(
() => uiCapability<T>(capabilityName, modules),
[capabilityName, modules]
);
}
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
return uiCapabilities<T>(capabilityName, useViewVisibleModules());
const modules = useViewVisibleModules();
return useMemo(
() => uiCapabilities<T>(capabilityName, modules),
[capabilityName, modules]
);
}
function useViewVisibleModules(): PlatformWebModule[] {
const modules = usePlatformModules();
const projection = useEffectiveView();
const surfaces = useViewSurfaces();
return modules.filter((module) =>
isViewSurfaceVisible(
projection,
moduleViewSurfaceId(module.id),
surfaces
)
return useMemo(
() =>
modules.filter((module) =>
isViewSurfaceVisible(
projection,
moduleViewSurfaceId(module.id),
surfaces
)
),
[modules, projection, surfaces]
);
}

View File

@@ -0,0 +1,163 @@
import {
apiFetch,
apiPath,
type ApiQueryParams
} from "../api/client";
import { fetchPlatformModules } from "../api/platform";
import { filterSearchableSelectOptions } from "../components/SearchableSelect";
import {
unavailableReferenceOption,
type ReferenceOption,
type ReferenceOptionProvider
} from "../components/ReferenceSelect";
import type { ApiSettings } from "../types";
type ApiReferenceOptionPayload = {
value: string;
label: string;
description?: string | null;
kind?: string | null;
availability?: ReferenceOption["availability"];
disabled?: boolean;
source_module?: string | null;
provenance?: Record<string, unknown>;
};
export function apiReferenceOptionProvider(
settings: ApiSettings,
path: string,
params: ApiQueryParams = {}
): ReferenceOptionProvider {
async function load(
query: string,
selectedValues: readonly string[],
limit: number,
signal: AbortSignal
): Promise<ReferenceOption[]> {
const response = await apiFetch<{
options: ApiReferenceOptionPayload[];
provider_available: boolean;
}>(
settings,
apiPath(path, {
...params,
q: query,
selected: selectedValues,
limit
}),
{ signal }
);
return response.options.map(referenceOptionFromPayload);
}
return {
search: (query, context) =>
load(query, context.selectedValues, context.limit, context.signal),
resolve: (values, context) =>
load("", values, context.limit ?? values.length, context.signal)
};
}
export function platformModuleReferenceProvider(
settings: ApiSettings,
presentedOptions: readonly ReferenceOption[] = []
): ReferenceOptionProvider {
const presentedByValue = new Map(
presentedOptions.map((option) => [option.value, option])
);
let cataloguePromise: Promise<ReferenceOption[]> | null = null;
async function catalogue(signal: AbortSignal): Promise<ReferenceOption[]> {
cataloguePromise ??= loadPlatformModuleOptions(
settings,
presentedOptions,
presentedByValue
).catch((error: unknown) => {
cataloguePromise = null;
throw error;
});
const options = await cataloguePromise;
if (signal.aborted) throw abortError();
return options;
}
return {
async search(query, context) {
const options = await catalogue(context.signal);
return filterSearchableSelectOptions(
options,
query,
context.limit
) as ReferenceOption[];
},
async resolve(values, context) {
const options = await catalogue(context.signal);
const byValue = new Map(
[...presentedOptions, ...options].map((option) => [
option.value,
option
])
);
return values.map(
(value) =>
byValue.get(value)
?? unavailableReferenceOption(value, "Unavailable module")
);
}
};
}
async function loadPlatformModuleOptions(
settings: ApiSettings,
presentedOptions: readonly ReferenceOption[],
presentedByValue: ReadonlyMap<string, ReferenceOption>
): Promise<ReferenceOption[]> {
let modules;
try {
modules = (await fetchPlatformModules(settings)).modules;
} catch (error) {
if (presentedOptions.length) return [...presentedOptions];
throw error;
}
return modules
.filter((module) => module.enabled)
.map((module) => {
const presented = presentedByValue.get(module.id);
return {
value: module.id,
label: presented?.label ?? module.name ?? module.id,
description: presented?.description
?? `${module.id} · version ${module.version}`,
searchText: presented?.searchText
?? `${module.id} ${module.name} ${module.version}`,
kind: "module",
availability: presented?.availability ?? "available",
disabled: presented?.disabled ?? false,
sourceModule: "core",
provenance: {
version: module.version,
activeRegistry: true,
...(presented?.provenance ?? {})
}
} satisfies ReferenceOption;
});
}
function referenceOptionFromPayload(
option: ApiReferenceOptionPayload
): ReferenceOption {
return {
value: option.value,
label: option.label,
description: option.description,
kind: option.kind,
availability: option.availability,
disabled: option.disabled,
sourceModule: option.source_module,
provenance: option.provenance
};
}
function abortError(): DOMException {
return new DOMException("The operation was aborted.", "AbortError");
}