From 39bf5389ad26c8a357e1e2cfc5607d7a9c6324c5 Mon Sep 17 00:00:00 2001 From: Paul Derscheid Date: Wed, 17 Sep 2025 15:41:28 +0200 Subject: [PATCH] Bug 36674: Lazy-load intranet API clients via proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced the eager imports in the intranet APIClient aggregator with lazy proxies so individual client modules are only fetched the first time they are actually used. Documented the pattern in the createClientProxy JSDoc, and kept the legacy synchronous API intact by forwarding property access, method calls, and promise chaining. The Vue aggregator remains on static imports for now because the rspack bundles already would handle chunking (which we need to configure) and would require broader consumer changes. This is a bit hacky, but only touches the api-client itself, which I like. Otherwise we would have to adjust all consumers. Test plan: 1. Log in to the staff interface and visit Admin → System preferences. 2. Open DevTools, Network tab, and enable “Disable cache”. 3. Trigger an API call (e.g. edit a preference and click “Save”, or run await APIClient.sysprefs.sysprefs.update_all({}).catch(()=>{}); from the console). 4. Confirm a new request for js/fetch/system-preferences-api-client.js appears and the page still behaves as before. 5. Theoretically we would need to test all call sites.. 6. Sign-off --- .../intranet-tmpl/prog/js/fetch/api-client.js | 230 +++++++++++++++--- .../prog/js/vue/fetch/api-client.js | 2 + 2 files changed, 204 insertions(+), 28 deletions(-) diff --git a/koha-tmpl/intranet-tmpl/prog/js/fetch/api-client.js b/koha-tmpl/intranet-tmpl/prog/js/fetch/api-client.js index 2fe5a6dce8c..918947db605 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/fetch/api-client.js +++ b/koha-tmpl/intranet-tmpl/prog/js/fetch/api-client.js @@ -1,33 +1,207 @@ import HttpClient from "./http-client.js"; -import ArticleRequestAPIClient from "./article-request-api-client.js"; -import AVAPIClient from "./authorised-values-api-client.js"; -import CataloguingAPIClient from "./cataloguing-api-client.js"; -import CirculationAPIClient from "./circulation-api-client.js"; -import ClubAPIClient from "./club-api-client.js"; -import CoverImageAPIClient from "./cover-image-api-client.js"; -import LocalizationAPIClient from "./localization-api-client.js"; -import PatronAPIClient from "./patron-api-client.js"; -import PatronListAPIClient from "./patron-list-api-client.js"; -import RecallAPIClient from "./recall-api-client.js"; -import SysprefAPIClient from "./system-preferences-api-client.js"; -import TicketAPIClient from "./ticket-api-client.js"; -import AcquisitionAPIClient from "./acquisition-api-client.js"; -import DefaultAPIClient from "./default-api-client.js"; +/** + * @template {object} T + * @typedef {new (...args: unknown[]) => T} ClientConstructor + */ + +/** + * @template {object} T + * @typedef {ClientConstructor | { default: ClientConstructor }} ClientModule + */ + +/** + * Determines whether a value can safely be treated as an object for property + * access (including functions, which are callable objects in JS). + * + * @param {unknown} value + * @returns {value is object | Function} + */ +const isObjectLike = value => + (typeof value === "object" && value !== null) || + typeof value === "function"; + +/** + * Lazily instantiates an API client module the first time a consumer actually + * uses it. Callers still interact with `APIClient.foo` synchronously, but under + * the hood a proxy defers the dynamic `import()` until a method is invoked or a + * promise chain is attached. This keeps existing call sites unchanged while + * preventing every specialised client from being fetched on initial page load. + * + * @template {object} T + * @param {() => Promise | ClientConstructor>} loader dynamic importer for the client module + * @returns {T} proxy exposing the API client interface with lazy loading + */ +const createClientProxy = loader => { + /** @type {Promise | undefined} */ + let instancePromise; + + /** + * Extracts the client constructor from a dynamic import namespace. + * + * @param {ClientModule | ClientConstructor} namespace + * @returns {ClientConstructor} + */ + const resolveClientConstructor = namespace => { + if (typeof namespace === "function") { + return /** @type {ClientConstructor} */ (namespace); + } + + if (isObjectLike(namespace)) { + const maybeDefault = Reflect.get( + /** @type {object} */ (namespace), + "default" + ); + if (typeof maybeDefault === "function") { + return /** @type {ClientConstructor} */ (maybeDefault); + } + } + throw new TypeError("API client module did not export a constructor"); + }; + + /** + * Resolves (or re-resolves after failure) the underlying client instance. + * + * @returns {Promise} promise resolving to the concrete client + */ + const loadInstance = () => { + if (!instancePromise) { + instancePromise = loader() + .then(resolveClientConstructor) + .then(Client => new Client(HttpClient)) + .catch(error => { + instancePromise = undefined; + throw error; + }); + } + return instancePromise; + }; + + /** + * Creates a proxy layer that defers property access and function calls + * until the client instance is available while keeping the existing call + * structure intact (including promise chaining support). + * + * @param {(client: T) => unknown} accessor resolver for the current target + * @param {(client: T) => unknown} [parentAccessor=accessor] context resolver + * @returns {unknown} proxy forwarding operations to the resolved target + */ + const createProxy = (accessor, parentAccessor = accessor) => { + /** + * Forwards promise chaining when consumers treat the proxy like a promise. + * + * @param {(value: unknown) => unknown} onFulfilled + * @param {(reason: unknown) => unknown} [onRejected] + * @returns {Promise} + */ + const handleThen = (onFulfilled, onRejected) => + loadInstance() + .then(client => accessor(client)) + .then(onFulfilled, onRejected); + + /** + * Propagates errors when consumers attach a catch handler to the proxy. + * + * @param {(reason: unknown) => unknown} onRejected + * @returns {Promise} + */ + const handleCatch = onRejected => + loadInstance() + .then(client => accessor(client)) + .catch(onRejected); + + /** + * Executes finally handlers while preserving the resolved value chain. + * + * @param {() => unknown} onFinally + * @returns {Promise} + */ + const handleFinally = onFinally => + loadInstance() + .then(client => accessor(client)) + .finally(onFinally); + + /** + * Returns a proxy that represents a nested property on the client. + * + * @param {PropertyKey} prop + * @returns {unknown} + */ + const forwardProperty = prop => + createProxy(client => { + const target = accessor(client); + if (!isObjectLike(target)) { + return undefined; + } + return Reflect.get(/** @type {object} */ (target), prop); + }, accessor); + + /** + * Invokes a method on the resolved client while keeping the original + * `this` binding semantics. + * + * @param {unknown} thisArg + * @param {unknown[]} argArray + * @returns {Promise} + */ + const invokeTarget = (thisArg, argArray) => + loadInstance().then(client => { + const target = accessor(client); + if (typeof target !== "function") { + throw new TypeError("API client property is not callable"); + } + const context = parentAccessor + ? parentAccessor(client) + : (thisArg ?? undefined); + return target.apply(context, argArray); + }); + + return new Proxy(function () {}, { + get(_, prop) { + if (prop === "then") { + return handleThen; + } + if (prop === "catch") { + return handleCatch; + } + if (prop === "finally") { + return handleFinally; + } + + return forwardProperty(prop); + }, + apply(_, thisArg, args) { + return invokeTarget(thisArg, /** @type {unknown[]} */ (args)); + }, + }); + }; + + return /** @type {T} */ (createProxy(client => client)); +}; export const APIClient = { - article_request: new ArticleRequestAPIClient(HttpClient), - authorised_values: new AVAPIClient(HttpClient), - acquisition: new AcquisitionAPIClient(HttpClient), - cataloguing: new CataloguingAPIClient(HttpClient), - circulation: new CirculationAPIClient(HttpClient), - club: new ClubAPIClient(HttpClient), - cover_image: new CoverImageAPIClient(HttpClient), - localization: new LocalizationAPIClient(HttpClient), - patron: new PatronAPIClient(HttpClient), - patron_list: new PatronListAPIClient(HttpClient), - recall: new RecallAPIClient(HttpClient), - sysprefs: new SysprefAPIClient(HttpClient), - ticket: new TicketAPIClient(HttpClient), - default: new DefaultAPIClient(HttpClient), + article_request: createClientProxy( + () => import("./article-request-api-client.js") + ), + authorised_values: createClientProxy( + () => import("./authorised-values-api-client.js") + ), + acquisition: createClientProxy(() => import("./acquisition-api-client.js")), + cataloguing: createClientProxy(() => import("./cataloguing-api-client.js")), + circulation: createClientProxy(() => import("./circulation-api-client.js")), + club: createClientProxy(() => import("./club-api-client.js")), + cover_image: createClientProxy(() => import("./cover-image-api-client.js")), + localization: createClientProxy( + () => import("./localization-api-client.js") + ), + patron: createClientProxy(() => import("./patron-api-client.js")), + patron_list: createClientProxy(() => import("./patron-list-api-client.js")), + recall: createClientProxy(() => import("./recall-api-client.js")), + sysprefs: createClientProxy( + () => import("./system-preferences-api-client.js") + ), + ticket: createClientProxy(() => import("./ticket-api-client.js")), + default: createClientProxy(() => import("./default-api-client.js")), }; + +export default APIClient; diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js b/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js index c12f54beebf..d7b00857aaa 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js @@ -21,3 +21,5 @@ export const APIClient = { preservation: new PreservationAPIClient(HttpClient), record_sources: new RecordSourcesAPIClient(HttpClient), }; + +export default APIClient; -- 2.39.5