Skip to content

Commit

Permalink
🐛 💥
Browse files Browse the repository at this point in the history
  • Loading branch information
jaandrle committed Jan 5, 2023
1 parent d43dfc5 commit ce7ffe8
Show file tree
Hide file tree
Showing 4 changed files with 344 additions and 2 deletions.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ typings/

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
Expand Down
211 changes: 211 additions & 0 deletions dist/esm.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
export type TopicOptions<DATA extends any, DATA_IN extends any= undefined>= {
/** Keep last published value and when new listener is registered call this function with kept value.
* @default false
* */
cache?: boolean;
/** This force `cache= true` and sets initial value. */
initial?: any;
/** Converts topic `value` from `publish` function to what listeners are expecting. */
mapper?: (value: DATA_IN)=> DATA;
/** Topic can be published only one time.
* @default false
* */
once?: boolean;
/** Topic origin
* @default null
* */
origin?: any;
};
/**
* Topic **reference** to be used in subscribe/publish/… functions.
* For using in JSDoc, you can use global type {@link fpubsubTopic}.
*/
export type Topic<DATA extends any, DATA_IN extends any= undefined>= TopicOptions<DATA, DATA_IN> & {
/** Topic origin */
origin: any;
/** Typically helpful in case of `once` topics. */
is_live: boolean;
};
declare global{
/** Alias for {@link Topic} for using in JSDoc */
export type fpubsubTopic<DATA extends any, DATA_IN extends any= undefined>= Topic<DATA, DATA_IN>;
}
type TopicOut<T>= T extends Topic<infer X> ? X : never
type TopicIn<T>= T extends Topic<infer Y , infer X> ? ( X extends undefined ? Y : X ) : never

/**
* Creates topic to be used in subscribe/publish/… functions.
* You can use another topis as argument for creating new topic with similar options (for dependent topic use {@link topicFrom}).
*
* Use types `topi<DATA, DATA_IN>`:
* - `DATA`: to add types for (publishign)/subscribing values
* - `DATA_IN`: to describe publishign values if differs to `DATA` (see {@link TopicOptions.mapper})
*
* In JavaScript:
* ```js
* /** @type {fpubsubTopic<string>} *\/
* const onexample= topic({ cached: true });
* //…
* publish(onexample, "Test");
* ```
* In TypeScript:
* ```ts
* const onexample= topic<string>({ cached: true });
* //…
* publish(onexample, "Test");
* ```
* @param options See {@link TopicOptions}
* */
export function topic<DATA extends any, DATA_IN extends any= undefined>(options?: TopicOptions<DATA, DATA_IN>): Topic<DATA, DATA_IN>;
/**
* Creates topic from [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) and maps abort signal as publish and publish as abort signal.
* Sets topic origin to the `AbortController` instance.
* ```js
* const onabort= topicFrom(AbortController);
* subscribe(onabort, console.log);
* fetch("www.example.test", { signal: onabort.origin.signal });
* publish(onabort);
* ```
*
* FYI: [Fetch: Abort](https://javascript.info/fetch-abort)
* */
export function topicFrom<DATA extends any, DATA_IN extends any= undefined>(candidate: AbortController | typeof AbortController): Topic<DATA, DATA_IN>;
/** Creates dependent topic to given topic. All listeners will be called when the original topic is published. */
export function topicFrom<DATA extends any, DATA_IN extends any= undefined>(candidate: Topic<any>): Topic<DATA, DATA_IN>;
/**
* ```js
* const is_topic= topic();
* const not_topic= {};
* console.log(
* isTopic(is_topic)===true,
* isTopic(not_topic)===false
* );
* ```
* */
export function isTopic<T>(candidate: T): T extends Topic<any, any> ? true : false;
/**
* Returns value of given topic. Primarly make sence in case of `cached` topics, elsewhere always returns `undefined`.
* ```js
* /** @type {fpubsubTopic<string>} *\/
* const ontest= topic({ cache: true });
* publish(topic, "value");
* console.log(valueOf(topic)==="value");
* ```
* */
export function valueOf<T extends Topic<any, any>>(topic: T): TopicOut<T> | undefined;
/**
* This function can be use to erase given `topic` explicitly.
* ```js
* const ontest= topic();
* subscribe(ontest, console.log);
* erase(ontest);
* publish(ontest);// throws error ⇐ no topic
* ```
* …but it is not neccesary:
* ```js
* let ontest= topic();
* subscribe(ontest, console.log);
* ontest= null;// JS auto remove unneeded info
* publish(ontest);// throws error ⇐ no topic
* ```
* …but keep in mind the `topic`s are objects (e.g. https://stackoverflow.com/a/6326813)
* */
export function erase(topic: Topic<any, any>): undefined;

/**
* Return type of functions:
* - `0`: operation successfully processed
* - `1`: given topic is not “live” (`once` event already published) → nothing to do
* - `2`, …: another non-error issue → nothing to do
*
* …functions typically throws Error if given topic is not {@link Topic}.
* */
export type ReturnStatus= 0 | 1 | 2;

/**
* Publishes `value` for given `topic`. Process all synchronous listeners synchronously, so if there is no async listener there is no need to await to `publish`.
*
* ```js
* /** @type {fpubsubTopic<string>} *\/
* const onexample= topic({ cached: true });
* publish(onexample, "Test");
* publish(onexample, "Test").then(console.log).catch(console.error);
*
* const publishExample= publish.bind(null, onexample);
* publishExample("Test 2");
*
* const publishText= publish("Test 3");
* publishText(onexample);
* ```
* @throws {TypeError} Given `topic` is not {@link Topic}!
* @returns 0= done, else see {@link ReturnStatus}
* */
export function publish<T extends Topic<any, any>>(topic: T, value?: TopicIn<T>): Promise<ReturnStatus>
export function publish<T extends Topic<any, any>>(value?: TopicIn<T>): (topic: T)=> Promise<ReturnStatus>
export { publish as pub };

/** Follows [EventTarget.addEventListener() - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener). */
export type Listener<T extends Topic<any, any>>=
( (value: TopicOut<T>, topic: T)=> void | Promise<void> )
| { handleEvent(value: TopicOut<T>, topic: T): void | Promise<void> }
;
export type SubscribeOptions= {
/** Call only once */
once?: boolean;
/** An AbortSignal. The listener will be removed when the given AbortSignal object's abort() method is called. If not specified, no AbortSignal is associated with the listener. */
signal?: AbortSignal;
};
/**
* Register `listener` function (subscriber) to be called when `topic` will be emitted.
*
* ```js
* /** @type {fpubsubTopic<string>} *\/
* const onexample= topic({ cached: true });
* subscribe(onexample, console.log);
*
* const options= {};
* const subscribeExample= subscribe(onexample, options);
* subscribeExample(console.error);
*
* const subscribeInfo= subscribe(console.info, options);
* subscribeInfo(onexample);
* ```
* @throws {TypeError} Given `topic` is not {@link Topic}!
* @returns 0= done, else see {@link ReturnStatus}
* */
export function subscribe<T extends Topic<any, any>>(topic: T, listener: Listener<T>, options?: SubscribeOptions): ReturnStatus
/** Curried version of `subscribe`. */
export function subscribe<T extends Topic<any, any>>(topic: T, options?: SubscribeOptions): (listener: Listener<T>)=> ReturnStatus
/** Curried version of `subscribe`. */
export function subscribe<T extends Topic<any, any>>(listener: Listener<T>, options?: SubscribeOptions): (topic: T)=> ReturnStatus
export { subscribe as sub };

/**
* Is `listener` listening to the given `topic`?
* @throws {TypeError} Given `topic` is not {@link Topic}!
* */
export function has<T extends Topic<any, any>>(topic: T, listener: Listener<T>): boolean;

/**
* Unregister `listener` function (subscriber) to be called when `topic` will be emitted.
* ```js
* const onexample= topic();
* subscribe(onexample, console.log);
* unsubscribe(onexample, console.log);
* ```
* @throws {TypeError} Given `topic` is not {@link Topic}!
* @returns 0= done, else see {@link ReturnStatus}
* */
export function unsubscribe<T extends Topic<any, any>>(topic: T, listener: (value: TopicOut<T>, topic: T)=> void | Promise<void>): ReturnStatus
export { unsubscribe as unsub };
/**
* Unregister all listeners for given `topic`.
* ```js
* const onexample= topic();
* subscribe(onexample, console.log);
* unsubscribeAll(onexample);
* ```
* @throws {TypeError} Given `topic` is not {@link Topic}!
* @returns 0= done, else see {@link ReturnStatus}
* */
export function unsubscribeAll(topic: Topic<any, any>): ReturnStatus
132 changes: 132 additions & 0 deletions dist/esm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/** @type {WeakMap<fpubsubTopic, { listeners?: Set<function>, value?: any }>} */
const storage= new WeakMap();
const Topic= {
origin: null,
get is_live(){
if(!storage.has(this)) return false;
return storage.get(this).listeners ? true : false;
}
};
export function topic(options){
options= Object.assign({}, options);
const topic= Object.assign(Object.create(Topic), options);
const storage_data= { listeners: new Set() };
storage.set(topic, storage_data);
if(Reflect.has(topic, "initial")){
storage_data.value= topic.initial;
Reflect.deleteProperty(topic, "initial");
}
return Object.freeze(topic);
}
const AbortController= globalThis && globalThis.AbortController ? globalThis.AbortController : class ignore{};
export function topicFrom(candidate, options){
if(candidate === AbortController)
candidate= new AbortController();
if(candidate instanceof AbortController)
return topicFromAbort(candidate);

if(isTopic(candidate)){
const t= topic(Object.assign({}, options, { origin: candidate }));
subscribe(candidate, value=> publishA(t, value));
return t;
}

if("[object AsyncGeneratorFunction]"===Object.prototype.toString.call(candidate)){
const t= topic(Object.assign({}, options, { origin: candidate }));
(async ()=> {
for await(const value of candidate())
publishA(t, value);
erase(t);
})();
return t;
}

throw new Error(`The '${candidate}' cannot be converted to a \`topic\`.`);
}
export function erase(topic){ storage.delete(topic); }
export function isTopic(candidate){ return Object.getPrototypeOf(candidate) === Topic; }
function notTopic(topic){
const topic_str= JSON.stringify(topic);
throw new TypeError(`Given topic '${topic_str}' is not supported. Topic are created via 'topic' function.`);
}
function isInactiveTopic(topic){
if(!isTopic(topic)) return notTopic(topic);

if(storage.get(topic).listeners) return 0;
if(topic.once) return 1;
return 2;
}
export function valueOf(topic){
if(!isTopic(topic)) return notTopic(topic);
return storage.get(topic).value;
}
function topicFromAbort(origin){
const options= topic({ once: true, origin });
const onabort= publishA.bind(null, origin, 0);
const onclose= ()=> (origin.signal.removeEventListener("abort", onabort), origin.abort());
origin.signal.addEventListener("abort", onabort);
subscribe(options, onclose);
return options;
}

export function publish(topic, value){
if(!isTopic(topic) && value===undefined) return t=> publishA(t, topic);
return publishA(topic, value);
}
async function publishA(topic, value){
if(isInactiveTopic(topic)) return 1;
value= toOutData(topic, value);
let promises= [];
storage.get(topic).listeners.forEach(function(f){
const p= typeof f === "function" ? f(value, topic) : f.handleEvent(value, topic);
if(p instanceof Promise) promises.push(p);
});
if(promises.length) await Promise.all(promises);
if(topic.cache) storage.get(topic).value= value;
if(topic.once) storage.get(topic).listeners= undefined;
return 0;
}
export const pub= publishA;
function toOutData({ mapper }, value){ return mapper ? mapper(value) : value; }

export function subscribe(topic, listener, { once= false, signal }= {}){
if(isListener(topic)) return t=> subscribe(t, topic, listener);
if(!isListener(listener)) return l=> subscribe(topic, l, listener);
if(signal instanceof AbortSignal){
if(signal.aborted) return 2;
signal.addEventListener("abort", unsubscribe.bind(null, topic, listener));
}
if(topic.cache) listener(storage.get(topic).value, topic);

if(isInactiveTopic(topic)) return 1;
if(!once){
storage.get(topic).listeners.add(listener);
return 0;
}
storage.get(topic).listeners.add(listenerOnce(listener));
return 0;
}
export const sub= subscribe;
function listenerOnce(listener){ return function listenerOnce(value){ listener(value); unsubscribe(topic, listenerOnce); }; }
function isListener(listener){
const type= typeof listener;
if(type==="function") return true;
if(type!=="object") return false;
if(!Reflect.has(listener, "handleEvent")) return false;
return typeof listener.handleEvent === "function";
}
export function unsubscribe(topic, listener){
if(isInactiveTopic(topic)) return 1;
return storage.get(topic).listeners.delete(listener) ? 0 : 2;
}
export const unsub= unsubscribe;
export function unsubscribeAll(topic){
if(isInactiveTopic(topic)) return 1;
storage.get(topic).listeners= new Set();
return 0;
}

export function has(topic, listener){
if(isInactiveTopic(topic)) return false;
return storage.get(topic).listeners.has(listener);
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fpubsub",
"version": "0.9.2",
"version": "0.9.3",
"description": "PubSub pattern library with types support",
"keywords": [
"event",
Expand Down

0 comments on commit ce7ffe8

Please sign in to comment.