diff --git a/package-lock.json b/package-lock.json index 6852563a..657956e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2319,6 +2319,10 @@ "resolved": "components/portal", "link": true }, + "node_modules/@byndyusoft-ui/pub-sub": { + "resolved": "services/pub-sub", + "link": true + }, "node_modules/@byndyusoft-ui/reset-css": { "resolved": "styles/reset-css", "link": true @@ -23675,6 +23679,10 @@ "version": "0.3.0", "license": "Apache-2.0" }, + "services/pub-sub": { + "version": "0.0.1", + "license": "Apache-2.0" + }, "styles/keyframes-css": { "name": "@byndyusoft-ui/keyframes-css", "version": "0.0.1", diff --git a/services/pub-sub/.npmignore b/services/pub-sub/.npmignore new file mode 100644 index 00000000..85de9cf9 --- /dev/null +++ b/services/pub-sub/.npmignore @@ -0,0 +1 @@ +src diff --git a/services/pub-sub/README.md b/services/pub-sub/README.md new file mode 100644 index 00000000..510ba67f --- /dev/null +++ b/services/pub-sub/README.md @@ -0,0 +1,119 @@ +# `@byndyusoft-ui/pub-sub` + +> A performant Pub/Sub interface with controlled instance management + +### Installation + +```bash +npm i @byndyusoft-ui/pub-sub +``` + +## Usage + +#### Import the class + +```ts +import PubSub from '@byndyusoft-ui/pub-sub'; +``` + +#### Define your channels +Create a type that defines the channels and their corresponding callback signatures. + +```ts +type ChannelsType = { + addTodo: (data: TodoType) => void; + removeTodo: (todoId: number) => void; + removeAll: () => void; + // For async callbacks: + asyncMessage: (data: string) => Promise; +}; +``` + +#### Create an instance +```ts +const pubSubInstance = new PubSub(); +``` + +#### Subscribe & Unsubscribe +Basic Subscription +```ts +const addTodoCallback = (data: TodoType) => { + console.log('Added new todo:', data); +}; + +// subscribe +pubSubInstance.subscribe('addTodo', addTodoCallback); + +// unsubscribe +pubSubInstance.unsubscribe('addTodo', addTodoCallback); +``` + +#### One-Time Subscription +Use `subscribeOnce` to subscribe to an event that should be handled only once: + +```ts +pubSubInstance.subscribeOnce('addTodo', (data) => { + console.log('This callback will only be executed once:', data); +}); +``` + +#### Unsubscribe All +Remove all callbacks from a specific channel or from all channels: + +```ts +// Unsubscribe all from a specific channel +pubSubInstance.unsubscribeAll('addTodo'); + +// Unsubscribe all from all channels +pubSubInstance.unsubscribeAll(); +``` + +#### Publish Events + +Synchronous Publish + +```ts +pubSubInstance.publish('addTodo', { id: 1, text: 'Some todo'}); +``` + +Asynchronous Publish +Use `publishAsync` to publish data and wait for asynchronous subscribers: + +```ts +pubSubInstance.subscribe('asyncMessage', async (data) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log(`Async received: ${data}`); +}); +``` + +#### Publish asynchronously +Use publishAsync to publish data and handle asynchronous subscribers. + +```ts + +pubSubInstance.subscribe('asyncMessage', async (data) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log(`Async received: ${data}`); +}); + +await pubSubInstance.publishAsync('asyncMessage', 'This is asynchronous!'); +``` + + +#### Get All Subscriptions +For debugging or monitoring, you can retrieve current subscriptions: + +```ts +const subscriptions = pubSubInstance.allSubscribes(); +console.log(subscriptions); +// Output example: +// [ { channel: 'addTodo', subscribers: 2 }, { channel: 'asyncMessage', subscribers: 1 } ] +``` + +#### Reset Subscriptions +Clear all channels and their subscribers: + +```ts +pubSubInstance.reset(); +``` + diff --git a/services/pub-sub/package.json b/services/pub-sub/package.json new file mode 100644 index 00000000..b6ccbb9e --- /dev/null +++ b/services/pub-sub/package.json @@ -0,0 +1,34 @@ +{ + "name": "@byndyusoft-ui/pub-sub", + "version": "0.0.1", + "description": "Byndyusoft UI Service", + "keywords": [ + "byndyusoft", + "byndyusoft-ui", + "channels", + "publish", + "subscribe", + "Pub/Sub" + ], + "author": "Gleb Fomin ", + "homepage": "https://github.com/Byndyusoft/ui/tree/master/services/pub-sub#readme", + "license": "Apache-2.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Byndyusoft/ui.git" + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "clean": "rimraf dist", + "lint": "eslint src --config ../../eslint.config.js", + "test": "jest --config ../../jest.config.js --roots services/pub-sub/src" + }, + "bugs": { + "url": "https://github.com/Byndyusoft/ui/issues" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/services/pub-sub/src/index.ts b/services/pub-sub/src/index.ts new file mode 100644 index 00000000..5bb500b0 --- /dev/null +++ b/services/pub-sub/src/index.ts @@ -0,0 +1 @@ +export { default } from './pubSub'; diff --git a/services/pub-sub/src/pubSub.tests.ts b/services/pub-sub/src/pubSub.tests.ts new file mode 100644 index 00000000..92450f2d --- /dev/null +++ b/services/pub-sub/src/pubSub.tests.ts @@ -0,0 +1,113 @@ +import PubSub from './pubSub'; + +describe('services/pub-sub', () => { + const pubSub = new PubSub(); + + afterEach(() => { + pubSub.reset(); + }); + + test('should subscribe and publish to a channel', () => { + const callback = jest.fn(); + pubSub.subscribe('testChannel', callback); + + pubSub.publish('testChannel', 'Hello, World!'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('Hello, World!'); + }); + + test('should not call callback if no subscribers', () => { + const callback = jest.fn(); + pubSub.publish('testChannel'); + + expect(callback).not.toHaveBeenCalled(); + }); + + test('should unsubscribe from a channel', () => { + const callback = jest.fn(); + pubSub.subscribe('testChannel', callback); + pubSub.unsubscribe('testChannel', callback); + + pubSub.publish('testChannel'); + + expect(callback).not.toHaveBeenCalled(); + }); + + test('should handle async subscribe callbacks', async () => { + const asyncCallback = jest.fn().mockResolvedValue(undefined); + pubSub.subscribe('asyncChannel', asyncCallback); + + await pubSub.publishAsync('asyncChannel', 'Async data'); + + expect(asyncCallback).toHaveBeenCalledTimes(1); + expect(asyncCallback).toHaveBeenCalledWith('Async data'); + }); + + test('should reset all subscriptions', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + pubSub.subscribe('testChannel', callback1); + pubSub.subscribe('testChannel', callback2); + + pubSub.reset(); + + pubSub.publish('testChannel'); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + }); + + test('should unsubscribe all callbacks for all channels using unsubscribeAll', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + pubSub.subscribe('testChannel', callback1); + pubSub.subscribe('asyncChannel', callback2); + + pubSub.unsubscribeAll(); + + pubSub.publish('testChannel', 'Test data'); + pubSub.publish('asyncChannel', 'Test data'); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + }); + + test('should call subscribeOnce callback only once', () => { + const callback = jest.fn(); + pubSub.subscribeOnce('testChannel', callback); + + // First publish should trigger the callback. + pubSub.publish('testChannel', 'Test message 1'); + + // Subsequent publish should not trigger the callback. + pubSub.publish('testChannel', 'Test message 2'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('Test message 1'); + }); + + test('should return all subscriptions info', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + pubSub.subscribe('testChannel', callback1); + pubSub.subscribe('testChannel', callback2); + pubSub.subscribe('asyncChannel', callback1); + + const result = pubSub.getAllSubscribers(); + + const testChannelInfo = result.find(item => item.event === 'testChannel'); + const asyncChannelInfo = result.find(item => item.event === 'asyncChannel'); + + expect(testChannelInfo).toBeDefined(); + + expect(testChannelInfo?.subscribers.length).toBe(2); + + expect(asyncChannelInfo).toBeDefined(); + + expect(asyncChannelInfo?.subscribers.length).toBe(1); + }); +}); diff --git a/services/pub-sub/src/pubSub.ts b/services/pub-sub/src/pubSub.ts new file mode 100644 index 00000000..e839bd2f --- /dev/null +++ b/services/pub-sub/src/pubSub.ts @@ -0,0 +1,66 @@ +type Callback = (data: unknown) => void; + +export default class PubSub { + private events: Map> = new Map(); + + subscribe(event: string, callback: Callback): void { + if (!this.events.has(event)) { + this.events.set(event, new Set()); + } + this.events.get(event)!.add(callback); + } + + subscribeOnce(event: string, callback: Callback): void { + const onceCallback: Callback = (data: unknown) => { + callback(data); // Execute the callback + this.unsubscribe(event, onceCallback); // Unsubscribe after execution + }; + this.subscribe(event, onceCallback); + } + + publish(event: string, data: unknown = null): void { + if (this.events.has(event)) { + this.events.get(event)!.forEach(callback => callback(data)); + } + } + + async publishAsync(event: string, data: unknown = null): Promise { + if (this.events.has(event)) { + const callbacks = Array.from(this.events.get(event)!); + // Execute all callbacks concurrently + await Promise.all(callbacks.map(callback => callback(data))); + } + } + + unsubscribe(event: string, callback: Callback): void { + if (this.events.has(event)) { + const callbacks = this.events.get(event)!; + callbacks.delete(callback); + + // Clean up the event if no callbacks are left + if (callbacks.size === 0) { + this.events.delete(event); + } + } + } + + unsubscribeAll(event?: string): void { + if (event) { + if (this.events.has(event)) { + this.events.delete(event); + } + } else { + this.events.clear(); + } + } + + reset(): void { + this.events.clear(); + } + + getAllSubscribers(): { event: string; subscribers: Callback[] }[] { + return Array.from(this.events.entries()).map(([event, callbacks]) => { + return { event, subscribers: Array.from(callbacks) }; + }); + } +} diff --git a/services/pub-sub/tsconfig.build.json b/services/pub-sub/tsconfig.build.json new file mode 100644 index 00000000..b4b36060 --- /dev/null +++ b/services/pub-sub/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/*.tests.ts"] +} diff --git a/services/pub-sub/tsconfig.json b/services/pub-sub/tsconfig.json new file mode 100644 index 00000000..5b7870da --- /dev/null +++ b/services/pub-sub/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "commonjs", + "target": "es6" + }, + "include": ["src"] +}