import { DEFAULT_CONSOLE_BASE_PATH, type ConsoleClientEntry, type ConsoleEntriesResponse, type ConsolePluginRegister, type PluginRegisterHostApi } from '@zhin.js/contract'; import type * as React from 'react'; export type FetchConsoleEntriesOptions = { entriesUrl?: string; signal?: AbortSignal; fetchInit?: RequestInit | (() => RequestInit); }; export type LoadConsoleEntriesOptions = FetchConsoleEntriesOptions & { /** Origin for `/@dev` / `/@assets` module URLs (Remote Console: Host API base). */ assetOrigin?: string; hostApi: PluginRegisterHostApi; beforeLoad?: () => void; onEmpty?: () => void; onFetchError?: (status: number) => void; onEntryError?: (entry: ConsoleClientEntry, error: unknown) => void; }; const CONSOLE_API_BASE_STORAGE_KEY = "zhin_api_base"; function resolveStoredApiBase(): string { if (typeof window === "undefined") return ""; const stored = localStorage.getItem(CONSOLE_API_BASE_STORAGE_KEY)?.trim(); return stored ? stored.replace(/\/$/, "") : ""; } function defaultEntriesUrl(): string { const apiBase = resolveStoredApiBase(); if (apiBase) return `${apiBase}/entries`; const root = DEFAULT_CONSOLE_BASE_PATH.replace(/\/$/, ""); return `${root ? `${root}/entries` : "/entries"}`; } function resolveRequestUrl(pathOrUrl: string): string { if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) return pathOrUrl; if (typeof window === "undefined") return pathOrUrl; const apiBase = resolveStoredApiBase(); const origin = apiBase || window.location.origin; return new URL(pathOrUrl, `${origin}/`).href; } function resolvePluginImportUrl(resolvedModule: string, assetOrigin?: string): string { if (resolvedModule.startsWith("http://") || resolvedModule.startsWith("https://")) return resolvedModule; if (typeof window === "undefined") return resolvedModule; const origin = assetOrigin?.replace(/\/$/, "") || window.location.origin; return new URL(resolvedModule, `${origin}/`).href; } function resolveFetchInit(init?: RequestInit | (() => RequestInit)): RequestInit { return typeof init === "function" ? init() : (init ?? {}); } export async function fetchConsoleEntries( options?: FetchConsoleEntriesOptions, ): Promise { const pathOrUrl = options?.entriesUrl ?? defaultEntriesUrl(); const url = resolveRequestUrl(pathOrUrl); const init = resolveFetchInit(options?.fetchInit); const res = await fetch(url, { ...init, signal: options?.signal ?? init.signal, } as RequestInit); if (!res.ok) throw Object.assign(new Error(`load entries failed: ${res.status}`), { status: res.status }); return (await res.json()) as ConsoleEntriesResponse; } export function getRegisterFn(mod: Record | null | undefined): ConsolePluginRegister | null { if (!mod) return null; const r = mod["register"]; if (typeof r === "function") return r as ConsolePluginRegister; const d = mod["default"] as Record | undefined; if (d && typeof d["register"] === "function") return d["register"] as ConsolePluginRegister; return null; } /** * Convention pages (ADR 0046) export a React component as `default` + optional `meta`, * without a legacy `register(api)`. Synthesize register from entry.route / entry meta * so Remote Console can still mount them. */ export function resolveEntryRegister( mod: Record | null | undefined, entry: ConsoleClientEntry & { readonly route?: string; readonly title?: string }, ): ConsolePluginRegister | null { const named = getRegisterFn(mod); if (named) return named; if (!mod) return null; const Component = mod["default"]; if (typeof Component !== "function" && !(Component && typeof Component === "object")) { return null; } const path = typeof entry.route === "string" && entry.route ? entry.route : `/${entry.id}`; const meta = mod["meta"] && typeof mod["meta"] === "object" ? mod["meta"] as { title?: string; icon?: string; hideInNav?: boolean } : undefined; const name = (typeof meta?.title === "string" && meta.title) || (typeof entry.title === "string" && entry.title) || entry.meta?.name || entry.id; return (api) => { api.addRoute({ path, name, element: api.React.createElement(Component as React.ComponentType), ...(meta?.icon != null ? { icon: meta.icon } : {}), meta: { hideInMenu: meta?.hideInNav === true }, }); }; } export async function registerConsolePluginsFromEntries( entries: ConsoleClientEntry[], hostApi: PluginRegisterHostApi, onEntryError?: (entry: ConsoleClientEntry, error: unknown) => void, assetOrigin?: string, ): Promise { if (!entries.length) return; await Promise.all( entries.map(async (entry) => { try { const specifier = resolvePluginImportUrl(entry.resolvedModule, assetOrigin); const mod = (await import(/* @vite-ignore */ specifier)) as Record; const register = resolveEntryRegister(mod, entry); if (register) await register(hostApi); else throw new Error(`entry "${entry.id}" has no register export`); } catch (error) { if (onEntryError) onEntryError(entry, error); else throw error; } }), ); } export async function loadConsoleEntries(options: LoadConsoleEntriesOptions): Promise { options.beforeLoad?.(); let data: ConsoleEntriesResponse; try { data = await fetchConsoleEntries(options); } catch (error) { const status = typeof (error as { status?: unknown }).status === "number" ? (error as { status: number }).status : 0; if (options.onFetchError) { options.onFetchError(status); return; } throw error; } if (!data.entries?.length) { options.onEmpty?.(); return; } await registerConsolePluginsFromEntries( data.entries, options.hostApi, options.onEntryError, options.assetOrigin, ); }