@v4fire/core
Version:
V4Fire core library
368 lines (315 loc) • 11.4 kB
text/typescript
/*!
* V4Fire Core
* https://github.com/V4Fire/Core
*
* Released under the MIT license
* https://github.com/V4Fire/Core/blob/master/LICENSE
*/
/**
* [[include:core/async/modules/wrappers/README.md]]
* @packageDocumentation
*/
import type { Provider } from 'core/data';
import type { CreateRequestOptions, RequestQuery, RequestBody } from 'core/request';
import Super, { AsyncOptions, EventEmitterLike } from 'core/async/modules/events';
import type { AsyncStorage, AsyncStorageNamespace } from 'core/kv-storage';
import {
emitLikeEvents,
asyncOptionsKeys,
dataProviderMethodsToReplace
} from 'core/async/modules/wrappers/consts';
import type {
WrappedDataProvider,
DataProviderMethodsToReplace,
DataProviderQueryMethodsToReplace,
EventEmitterWrapper,
EventEmitterOverwritten,
WrappedAsyncStorageNamespace,
WrappedAsyncStorage,
AsyncOptionsForWrappers
} from 'core/async/interface';
export * from 'core/async/modules/events';
export * from 'core/async/modules/wrappers/consts';
export * from 'core/async/interface';
export default class Async<CTX extends object = Async<any>> extends Super<CTX> {
/**
* The wrapper takes a link to the "raw" data provider and returns a new object that based
* on the original, but all async methods and properties are wrapped by Async.
* Notice, the wrapped methods can take additional Async parameters, like group or label.
* If you don't provide a group, it will be taken from the provider name.
*
* @param provider
* @param [opts] - additional options for the wrapper
*
* @example
* ```js
* import Async from 'core/async';
* import Provider, { provider } from 'core/data';
*
* @provider('api')
* export default class User extends Provider {
* baseURL = 'user/:id';
* }
*
* const
* $a = new Async(),
* wrappedProvider = $a.wrapDataProvider(new User());
*
* wrappedProvider.get({uuid: 1}).then((res) => {
* console.log(res);
* });
*
* // By default, all wrapped methods have a group name that is equal to the provider name.
* // So we can use it to clear or suspend requests, etc.
* $a.clearAll({group: 'api.User'})
*
* wrappedProvider.upd({uuid: 1}, {
* // All wrapped methods can take additional Async parameters as the second argument: `group`, `label` and `join`
* group: 'bla',
* label: 'foo',
* join: true,
*
* // Also, the second argument of the wrapped method can take the original parameters from a provider
* headers: {
* 'X-Foo': '1'
* }
*
* }).then((res) => {
* console.log(res);
* });
*
* // If we are providing a group to the method, it will be joined with the global group by using the `:` character
* $a.suspendAll({group: 'api.User:bla'});
*
* // Obviously, we can use a group as RegExp
* $a.muteAll({group: /api\.User/});
*
* // We can use any methods or properties from the original data provider
* wrappedProvider.dropCache();
* ```
*/
wrapDataProvider<
P extends Provider,
W extends WrappedDataProvider
>(provider: P, opts?: AsyncOptionsForWrappers):W {
const
wrappedProvider: W = Object.create(provider),
wrappedProviderGroup = opts?.group ?? provider.providerName;
for (let i = 0; i < dataProviderMethodsToReplace.length; i++) {
const methodName = dataProviderMethodsToReplace[i];
wrappedProvider[methodName] = <D = unknown>(
body?: RequestBody | RequestQuery,
opts?: CreateRequestOptions<D> & AsyncOptions
) => {
const
ownParams = Object.reject(opts, asyncOptionsKeys),
asyncParams = Object.select(opts, asyncOptionsKeys),
group = `${wrappedProviderGroup}${asyncParams.group != null ? `:${asyncParams.group}` : ''}`;
if (isQueryMethod(methodName)) {
return this.request(provider[methodName](<RequestQuery>body, ownParams), {
...asyncParams,
group
});
}
return this.request(provider[methodName](<RequestBody>body, ownParams), {
...asyncParams,
group
});
};
}
wrappedProvider.emitter = this.wrapEventEmitter(provider.emitter, opts);
return wrappedProvider;
function isQueryMethod(name: DataProviderMethodsToReplace): name is DataProviderQueryMethodsToReplace {
return ['get', 'peek'].includes(name);
}
}
/**
* The wrapper takes a link to the "raw" event emitter and returns a new object that based
* on the original, but all async methods and properties are wrapped by Async.
* Notice, the wrapped methods can take additional Async parameters, like group or label.
* In addition, the wrapper adds new methods, like "on" or "off", to make the emitter API more standard.
*
* @param emitter
* @param [opts] - additional options for the wrapper
*
* @example
* ```js
* import Async from 'core/async';
*
* const
* $a = new Async(),
* wrappedEventEmitter = $a.wrapEventEmitter(window);
*
* const handler = () => console.log('scroll event');
*
* // We can safely listen to emitter events,
* // cause all emitter methods, like `addListener` or `on` are wrapped by Async.
* const id = wrappedEventEmitter.addEventListener('scroll', handler, {
* // Notice, the third argument can take Async parameters in addition to the native emitter parameters
* capture: true,
* label: 'label'
* });
*
* // The wrapper preserves the original API of emitter methods, so we can call something like this
* wrappedEventEmitter.removeEventListener('scroll', handler);
*
* // Finally, the wrapper adds a bunch of standard methods to the emitter, like `on`, `once`, and other stuff.
* // We can use their instead of the original methods to make our code more universal.
* wrappedEventEmitter.once('resize', (e) => {
* console.log(e);
* }, {group: 'resizers'});
*
* $a.muteAll({group: 'resizers'});
*
* // We can use any methods or properties from the original emitter
* console.log(wrappedEventEmitter.name); // window.name
* ```
*/
wrapEventEmitter<T extends EventEmitterLike>(
emitter: T,
opts?: AsyncOptionsForWrappers
): (EventEmitterOverwritten<T> & EventEmitterWrapper) {
const wrappedEmitter = Object.create(emitter);
wrappedEmitter.on = (event, fn, ...params) => {
if (!Object.isFunction(fn)) {
throw new TypeError('Wrapped emitters methods `on, addEventListener, addListener` accept only a function as the second parameter');
}
return this.on(emitter, event, fn, ...normalizeAdditionalArgs(params));
};
wrappedEmitter.addEventListener = wrappedEmitter.on;
wrappedEmitter.addListener = wrappedEmitter.on;
wrappedEmitter.once =
(event, fn, ...params) => this.once(emitter, event, fn, ...normalizeAdditionalArgs(params));
wrappedEmitter.promisifyOnce =
(event, ...params) => this.promisifyOnce(emitter, event, ...normalizeAdditionalArgs(params));
const wrapOff = (originalMethod) => (link, ...args) => {
if (link == null || typeof link !== 'object' || args.length > 0) {
return Object.isFunction(originalMethod) ? originalMethod.call(emitter, link, ...args) : null;
}
return this.off(link);
};
wrappedEmitter.off = wrapOff(emitter.off);
wrappedEmitter.removeEventListener = wrapOff(emitter.removeEventListener);
wrappedEmitter.removeListener = wrapOff(emitter.removeListener);
wrappedEmitter.emit = (event, ...args) => {
for (let i = 0; i < emitLikeEvents.length; i++) {
const
key = emitLikeEvents[i],
property = emitter[key];
if (Object.isFunction(property)) {
return property.call(emitter, event, ...args);
}
}
};
return wrappedEmitter;
function normalizeAdditionalArgs(params: unknown[]): unknown[] {
if (Object.isPlainObject(params[0])) {
const
ownParam = Object.reject(params[0], asyncOptionsKeys),
asyncParam = Object.select(params[0], asyncOptionsKeys);
return [
{
...asyncParam,
group: [opts?.group, asyncParam.group].filter(Boolean).join(':')
},
ownParam,
...params.slice(1)
];
}
return [opts?.group != null ? {group: opts.group} : {}, ...params];
}
}
/**
* The wrapper takes a link to the "raw" async storage and returns a new object that based
* on the original, but all async methods and properties are wrapped by Async.
* Notice, the wrapped methods can take additional Async parameters, like group or label.
*
* @param storage
* @param opts
*
* @example
* ```js
* import Async from 'core/async';
* import { asyncLocal } from 'core/kv-storage';
*
* const
* $a = new Async(),
* wrappedStorage = $a.wrapStorage(asyncLocal, {group: 'bar'});
*
* wrappedStorage.set('someKey', 'someValue', {
* // If we are providing a group to the method, it will be joined with the global group by using the `:` character
* group: 'bla',
* label: 'foo',
* join: true,
* }).then(async () => {
* console.log(await wrappedStorage.get('someKey') === 'someValue');
* });
*
* $a.suspendAll({group: 'bar:bla'});
*
* // We can provide own global group to namespace, it will be joined with the parent's global group
* const blaStore = wrappedStorage.namespace('[[BLA]]', {group: 'bla'});
*
* blaStore.clear({group: 'foo'});
*
* $a.muteAll({group: 'bar:bla:foo'});
* ```
*/
wrapStorage<T extends AsyncStorage | AsyncStorageNamespace>(
storage: T,
opts?: AsyncOptionsForWrappers
): T extends AsyncStorage ? WrappedAsyncStorage : WrappedAsyncStorageNamespace {
const
globalGroup = opts?.group,
wrappedStorage = Object.create(storage);
wrappedStorage.has = (key, ...args) => {
const [asyncOpts, params] = separateArgs(args);
return this.promise(storage.has(key, ...params), asyncOpts);
};
wrappedStorage.get = <T = unknown>(key, ...args) => {
const [asyncOpts, params] = separateArgs(args);
return this.promise(storage.get<T>(key, ...params), asyncOpts);
};
wrappedStorage.set = (key, value, ...args) => {
const [asyncOpts, params] = separateArgs(args);
return this.promise(storage.set(key, value, ...params), asyncOpts);
};
wrappedStorage.remove = (key, ...args) => {
const [asyncOpts, params] = separateArgs(args);
return this.promise(storage.remove(key, ...params), asyncOpts);
};
wrappedStorage.clear = <T = unknown>(filter?, ...args) => {
if (Object.isPlainObject(filter)) {
filter = undefined;
args = [filter];
}
const [asyncOpts, params] = separateArgs(args);
return this.promise(storage.clear<T>(filter, ...params), asyncOpts);
};
if ('namespace' in storage) {
wrappedStorage.namespace = (name, opts?) => {
const [asyncOpts] = separateArgs([opts]);
const storageNamespace = storage.namespace(name);
return this.wrapStorage(storageNamespace, asyncOpts);
};
}
return wrappedStorage;
function separateArgs(args: unknown[]): [AsyncOptions, unknown[]] {
const lastArg = args.pop();
if (!Object.isDictionary(lastArg)) {
return [globalGroup != null ? {group: globalGroup} : {}, [...args, lastArg]];
}
const
ownParams = Object.reject(lastArg, asyncOptionsKeys),
asyncParams = Object.select(lastArg, asyncOptionsKeys),
group = [globalGroup, asyncParams.group].filter(Boolean).join(':');
if (group !== '') {
asyncParams.group = group;
}
if (Object.keys(ownParams).length !== 0) {
args.push(ownParams);
}
return [asyncParams, args];
}
}
}