Bug 36674: Lazy-load intranet API clients via proxy
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
Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lisette Scheer <lisette@bywatersolutions.com>
Signed-off-by: Laura Escamilla <laura.escamilla@bywatersolutions.com>
This commit is contained in:
parent
0f09e95f97
commit
c6612349ab
|
|
@ -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<T> | { default: ClientConstructor<T> }} 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<ClientModule<T> | ClientConstructor<T>>} loader dynamic importer for the client module
|
||||
* @returns {T} proxy exposing the API client interface with lazy loading
|
||||
*/
|
||||
const createClientProxy = loader => {
|
||||
/** @type {Promise<T> | undefined} */
|
||||
let instancePromise;
|
||||
|
||||
/**
|
||||
* Extracts the client constructor from a dynamic import namespace.
|
||||
*
|
||||
* @param {ClientModule<T> | ClientConstructor<T>} namespace
|
||||
* @returns {ClientConstructor<T>}
|
||||
*/
|
||||
const resolveClientConstructor = namespace => {
|
||||
if (typeof namespace === "function") {
|
||||
return /** @type {ClientConstructor<T>} */ (namespace);
|
||||
}
|
||||
|
||||
if (isObjectLike(namespace)) {
|
||||
const maybeDefault = Reflect.get(
|
||||
/** @type {object} */ (namespace),
|
||||
"default"
|
||||
);
|
||||
if (typeof maybeDefault === "function") {
|
||||
return /** @type {ClientConstructor<T>} */ (maybeDefault);
|
||||
}
|
||||
}
|
||||
throw new TypeError("API client module did not export a constructor");
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves (or re-resolves after failure) the underlying client instance.
|
||||
*
|
||||
* @returns {Promise<T>} 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<unknown>}
|
||||
*/
|
||||
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<unknown>}
|
||||
*/
|
||||
const handleCatch = onRejected =>
|
||||
loadInstance()
|
||||
.then(client => accessor(client))
|
||||
.catch(onRejected);
|
||||
|
||||
/**
|
||||
* Executes finally handlers while preserving the resolved value chain.
|
||||
*
|
||||
* @param {() => unknown} onFinally
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
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<unknown>}
|
||||
*/
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -21,3 +21,5 @@ export const APIClient = {
|
|||
preservation: new PreservationAPIClient(HttpClient),
|
||||
record_sources: new RecordSourcesAPIClient(HttpClient),
|
||||
};
|
||||
|
||||
export default APIClient;
|
||||
|
|
|
|||
Loading…
Reference in New Issue