main
js 270 lines 8.37 KB
Raw
1 import * as api from "./api.js";
2 import * as cache from "./cache.js";
3
4 /**
5 * @typedef {string} WebuiExtension
6 */
7
8
9
10 /**
11 * @typedef {Object} LoadWebuiExtensionsResponse
12 * @property {WebuiExtension[]} extensions
13 */
14
15 /**
16 * @typedef {Object} JsExtensionImport
17 * @property {string} path
18 * @property {{ default: (...data: any[]) => (void|Promise<void>) }} module
19 */
20
21 const JS_CACHE_AREA = "frontend_extensions_js(extensions)(plugins)";
22 const HTML_CACHE_AREA = "frontend_extensions_html(extensions)(plugins)";
23 let alpineInitialized = false;
24 const LOADING_SELECTOR = "x-component > .loading:empty, x-extension.loading";
25
26 export let initialHtmlExtensionsLoaded = false;
27
28 function checkInitialLoadComplete() {
29 if (initialHtmlExtensionsLoaded || !alpineInitialized || document.querySelector(LOADING_SELECTOR)) return;
30 globalThis.Alpine.nextTick(() => {
31 if (initialHtmlExtensionsLoaded || document.querySelector(LOADING_SELECTOR)) return;
32 initialHtmlExtensionsLoaded = true;
33 document.dispatchEvent(new Event("webui-extensions-loaded"));
34 });
35 }
36
37 export const API_EXTENSION_EXCLUDED_ENDPOINTS = new Set([
38 "/api/load_webui_extensions",
39 ]);
40
41 export function clearCache() {
42 cache.clear(JS_CACHE_AREA);
43 cache.clear(HTML_CACHE_AREA);
44 }
45
46 function manifestExtensionPaths(assetType, extensionPoint) {
47 const manifest = globalThis.runtimeInfo?.webuiExtensions;
48 if (!manifest || typeof manifest !== "object") return null;
49 const extensionsByPoint = manifest[assetType];
50 if (!extensionsByPoint || typeof extensionsByPoint !== "object") return null;
51 const extensions = extensionsByPoint[extensionPoint];
52 return Array.isArray(extensions) ? extensions : [];
53 }
54
55 /**
56 * Call all JS extensions for a given extension point.
57 *
58 * @param {string} extensionPoint
59 * @param {...any} data
60 * @returns {Promise<void>}
61 */
62 export async function callJsExtensions(extensionPoint, ...data){
63 const extensions = cache.get(JS_CACHE_AREA, extensionPoint, null) || await loadJsExtensions(extensionPoint);
64 for(const extension of extensions){
65 try{
66 await extension.module.default(...data);
67 }catch(error){
68 console.error(`Error calling extension: ${extension.path}`, error);
69 }
70 }
71 }
72
73 /**
74 * Load JS extension modules for an extension point.
75 *
76 * @param {string} extensionPoint
77 * @returns {Promise<JsExtensionImport[]>}
78 */
79 export async function loadJsExtensions(extensionPoint) {
80 try {
81 const cached = cache.get(JS_CACHE_AREA, extensionPoint, null);
82 if (cached != null) return cached;
83
84 const manifestExtensions = manifestExtensionPaths("js", extensionPoint);
85 /** @type {WebuiExtension[]} */
86 let extensionPaths = manifestExtensions;
87 if (extensionPaths == null) {
88 /** @type {LoadWebuiExtensionsResponse} */
89 const response = await api.callJsonApi(`/api/load_webui_extensions`, {
90 extension_point: extensionPoint,
91 filters: ["*.js", "*.mjs"],
92 });
93 extensionPaths = response.extensions;
94 }
95 /** @type {JsExtensionImport[]} */
96 const imports = await Promise.all(
97 extensionPaths.map(async (path) => ({
98 path,
99 module: await import(normalizePath(path))
100 }))
101 );
102 cache.add(JS_CACHE_AREA, extensionPoint, imports);
103 return imports;
104 } catch (error) {
105 console.error("Error loading JS extensions:", error);
106 return [];
107 }
108 }
109
110 // Load all x-component tags starting from root elements
111 /**
112 * Load and render all HTML extensions in the given DOM roots.
113 *
114 * @param {Element | Document | Array<Element | Document>} [roots]
115 * @returns {Promise<void>}
116 */
117 export async function loadHtmlExtensions(roots = [document.documentElement]) {
118 try {
119 // Convert single root to array if needed
120 /** @type {Array<Element | Document>} */
121 const rootElements = Array.isArray(roots) ? roots : [roots];
122
123 // Find all top-level components and load them in parallel
124 /** @type {Element[]} */
125 const extensions = rootElements.flatMap((root) =>
126 Array.from(root.querySelectorAll("x-extension")),
127 );
128
129 if (extensions.length === 0) return;
130
131 await Promise.all(
132 extensions.map(async (extension) => {
133 const path = extension.getAttribute("id");
134 if (!path) {
135 console.error("x-extension missing id attribute:", extension);
136 return;
137 }
138 await importHtmlExtensions(path, /** @type {HTMLElement} */ (extension));
139 }),
140 );
141 } catch (error) {
142 console.error("Error loading HTML extensions:", error);
143 }
144 }
145
146 /**
147 * Reload and re-render all HTML extensions in the given DOM roots.
148 *
149 * @param {Element | Document | Array<Element | Document>} [roots]
150 * @returns {Promise<void>}
151 */
152 export async function reloadHtmlExtensions(roots = [document.documentElement]) {
153 try {
154 /** @type {Array<Element | Document>} */
155 const rootElements = Array.isArray(roots) ? roots : [roots];
156
157 /** @type {Element[]} */
158 const extensions = rootElements.flatMap((root) =>
159 Array.from(root.querySelectorAll("x-extension")),
160 );
161
162 if (extensions.length === 0) return;
163
164 await Promise.all(
165 extensions.map(async (extension) => {
166 const path = extension.getAttribute("id");
167 if (!path) {
168 console.error("x-extension missing id attribute:", extension);
169 return;
170 }
171
172 extension.innerHTML = "";
173 await importHtmlExtensions(path, /** @type {HTMLElement} */ (extension));
174 }),
175 );
176 } catch (error) {
177 console.error("Error reloading HTML extensions:", error);
178 }
179 }
180
181 // import all extensions for extension point via backend api
182 /**
183 * Import all HTML extensions for an extension point and inject them as `<x-component>` tags.
184 *
185 * @param {string} extensionPoint
186 * @param {HTMLElement} targetElement
187 * @returns {Promise<void>}
188 */
189 export async function importHtmlExtensions(extensionPoint, targetElement) {
190 targetElement.classList.add("loading");
191 try {
192 const cachedHtml = cache.get(HTML_CACHE_AREA, extensionPoint, null);
193 if (cachedHtml != null) {
194 targetElement.innerHTML = cachedHtml;
195 return;
196 }
197
198 const manifestExtensions = manifestExtensionPaths("html", extensionPoint);
199 /** @type {WebuiExtension[]} */
200 let extensionPaths = manifestExtensions;
201 if (extensionPaths == null) {
202 /** @type {LoadWebuiExtensionsResponse} */
203 const response = await api.callJsonApi(`/api/load_webui_extensions`, {
204 extension_point: extensionPoint,
205 filters: ["*.html", "*.htm", "*.xhtml"],
206 });
207 extensionPaths = response.extensions;
208 }
209 let combinedHTML = "";
210 for (const extension of extensionPaths) {
211 const path = normalizePath(extension);
212 combinedHTML += `<x-component path="${path}"></x-component>`;
213 }
214 cache.add(HTML_CACHE_AREA, extensionPoint, combinedHTML);
215 targetElement.innerHTML = combinedHTML;
216 } catch (error) {
217 console.error("Error importing HTML extensions:", error);
218 return;
219 } finally {
220 targetElement.classList.remove("loading");
221 checkInitialLoadComplete();
222 }
223 }
224
225 /**
226 * @param {string} path
227 * @returns {string}
228 */
229 function normalizePath(path) {
230 return path.startsWith("/") ? path : "/" + path;
231 }
232
233 // Watch for DOM changes to dynamically load x-extensions
234 /** @type {MutationCallback} */
235 const extensionObserverCallback = (mutations) => {
236 for (const mutation of mutations) {
237 for (const node of mutation.addedNodes) {
238 if (node.nodeType === 1) {
239 // ELEMENT_NODE
240 // Check if this node or its descendants contain x-extension(s)
241 const el = /** @type {Element} */ (node);
242 if (el.matches?.("x-extension")) {
243 const id = el.getAttribute("id");
244 if (id) importHtmlExtensions(id, /** @type {HTMLElement} */ (el));
245 } else if (/** @type {any} */ (el)["querySelectorAll"]) {
246 loadHtmlExtensions([el]);
247 }
248 }
249 }
250 }
251 checkInitialLoadComplete();
252 };
253
254 /** @type {MutationObserver} */
255 const extensionObserver = new MutationObserver(extensionObserverCallback);
256 extensionObserver.observe(document.body, { childList: true, subtree: true });
257
258 document.addEventListener("alpine:initialized", () => {
259 alpineInitialized = true;
260 checkInitialLoadComplete();
261 }, { once: true });
262
263 // Do an initial scan for static x-extension tags
264 // that already exist in the DOM (index.html), then rely on
265 // the observer for dynamically inserted nodes coming from components.
266 if (document.readyState === "loading") {
267 document.addEventListener("DOMContentLoaded", () => loadHtmlExtensions());
268 } else {
269 loadHtmlExtensions();
270 }