-
Notifications
You must be signed in to change notification settings - Fork 0
service: pub-sub #141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
service: pub-sub #141
Changes from 2 commits
6df5c30
9140df7
194d918
73fb12d
130b64a
d8e72d2
c04f8ad
9304573
72ed8eb
87f20f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| src |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| # `@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; | ||
| }; | ||
| ``` | ||
|
|
||
| #### Create an instance | ||
| Use the `getInstance` method to create or retrieve a singleton instance of `PubSub`. | ||
|
|
||
| ```ts | ||
| const pubSubInstance = PubSub.getInstance<ChannelsType>(); | ||
| ``` | ||
|
|
||
| #### Subscribe and unsubscribe to a channel | ||
| Remove a specific callback from a channel to stop receiving notifications. | ||
|
|
||
| ```ts | ||
| const addTodoCallback = (data: TodoType) => { | ||
| console.log('Added new todo:', data); | ||
| }; | ||
|
|
||
| const removeTodoCallback = (todoId: number) => { | ||
| console.log(`Removed todo: ${todoId}`); | ||
| }; | ||
|
|
||
| const removeAllCallback = () => { | ||
| console.log('All todos deleted'); | ||
| }; | ||
|
|
||
| // subscribe | ||
| pubSubInstance.subscribe('addTodo', addTodoCallback); | ||
| pubSubInstance.subscribe('removeTodo', removeTodoCallback); | ||
| pubSubInstance.subscribe('removeAll', removeAllCallback); | ||
|
|
||
| // unsubscribe | ||
| pubSubInstance.unsubscribe('addTodo', addTodoCallback); | ||
| pubSubInstance.unsubscribe('removeTodo', removeTodoCallback); | ||
| pubSubInstance.unsubscribe('removeAll', removeAllCallback); | ||
|
|
||
| ``` | ||
|
|
||
| #### Publish to a channel | ||
|
|
||
| ```ts | ||
| pubSubInstance.publish('addTodo', { id: 1, text: 'Some todo'}); | ||
| pubSubInstance.publish('removeTodo', 1); | ||
| pubSubInstance.publish('removeAll'); | ||
| ``` | ||
|
|
||
| #### 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!'); | ||
| ``` | ||
|
|
||
| #### Reset all subscriptions | ||
| Clear all channels and their associated subscribers. | ||
|
|
||
| ```ts | ||
| pubSubInstance.reset(); | ||
| ``` | ||
|
|
||
| #### Singleton Instances | ||
| `PubSub` supports multiple named singleton instances using instanceKey. | ||
| This allows you to create isolated instances for different parts of your application. | ||
|
|
||
| > Note: If instanceKey is not provided, the instance with the default name `"_default"` will be used. | ||
|
|
||
| Usage example: | ||
| ```ts | ||
| import PubSub from '@byndyusoft-ui/pub-sub'; | ||
|
|
||
| // Get the instance with the default name "_default" | ||
| const defaultPubSub = PubSub.getInstance(); | ||
|
|
||
| // Get an instance with a custom name | ||
| const customPubSub = PubSub.getInstance('custom'); | ||
|
|
||
| // Instances are isolated from each other | ||
| defaultPubSub.subscribe('event', (data) => { | ||
| console.log(`Default instance: ${data}`); | ||
| }); | ||
|
|
||
| customPubSub.subscribe('event', (data) => { | ||
| console.log(`Custom instance: ${data}`); | ||
| }); | ||
|
|
||
| // Publish events in different instances | ||
| defaultPubSub.publish('event', 'Hello from default!'); | ||
| customPubSub.publish('event', 'Hello from custom!'); | ||
| ``` | ||
|
|
||
| #### Adapter for Interfaces | ||
| If you're using `interface` instead of `type`, you can use the helper type | ||
| `ChannelsRecordAdapter` to ensure compatibility with the index signature: | ||
|
|
||
| ```ts | ||
| import { type ChannelsRecordAdapter } from '@byndyusoft-ui/pub-sub' | ||
|
|
||
| interface TodoChannels { | ||
| addTodo: (data: TodoType) => void; | ||
| removeTodo: (todoId: number) => void; | ||
| removeAll: () => void; | ||
| } | ||
|
|
||
| const pubSubInstance = PubSub.getInstance<ChannelsRecordAdapter<TodoChannels>>(); | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <gleb.fom28@gmail.com>", | ||
| "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" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export { default } from './pubSub'; | ||
|
|
||
| export type { ChannelsRecordAdapter } from './pubSub.types'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import PubSub from './pubSub'; | ||
|
|
||
| type TChannels = { | ||
| testChannel: (data?: string) => void; | ||
| asyncChannel: (data?: string) => Promise<void>; | ||
| }; | ||
|
|
||
| describe('services/pub-sub', () => { | ||
| let pubSub: PubSub<TChannels>; | ||
|
|
||
| beforeEach(() => { | ||
| pubSub = PubSub.getInstance<TChannels>(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| pubSub.reset(); | ||
| }); | ||
|
|
||
| test('should create a new instance and get the same instance for the same key', () => { | ||
| const instance1 = PubSub.getInstance<TChannels>('instance1'); | ||
| const instance2 = PubSub.getInstance<TChannels>('instance1'); | ||
| const instance3 = PubSub.getInstance<TChannels>('instance2'); | ||
|
|
||
| expect(instance1).toBe(instance2); | ||
| expect(instance1).not.toBe(instance3); | ||
| }); | ||
|
|
||
| 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 warn if no subscribers are present for a channel', () => { | ||
| console.warn = jest.fn(); | ||
|
|
||
| pubSub.publish('testChannel', 'No one is listening'); | ||
|
|
||
| expect(console.warn).toHaveBeenCalledWith('No subscribers for channel: testChannel'); | ||
| }); | ||
|
|
||
| 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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { TChannelData, TChannelMap, TDefaultChannels, TPubSubInstances } from './pubSub.types'; | ||
|
|
||
| const DEFAULT_NAME_INSTANCE = '_default'; | ||
|
|
||
| class PubSub<ChannelsRecord extends TDefaultChannels> { | ||
| private static instances: TPubSubInstances = new Map(); | ||
| private channels: TChannelMap<ChannelsRecord> = new Map(); | ||
|
|
||
| private constructor() {} | ||
|
|
||
| /** | ||
| * Getting an instance of a class. | ||
| */ | ||
| static getInstance<ChannelsRecord extends TDefaultChannels>( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Может не стоит делать сервис синглтоном? Пусть в месте, где этот сервис используется, отдельно решается быть ему синглтоном или нет.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Можно убрать instances и getInstance. Я правильно понял, что использоваться должно вот так: |
||
| instanceKey: string = DEFAULT_NAME_INSTANCE | ||
| ): PubSub<ChannelsRecord> { | ||
| if (!this.instances.get(instanceKey)) { | ||
| this.instances.set(instanceKey, new PubSub<ChannelsRecord>()); | ||
| } | ||
|
|
||
| return this.instances.get(instanceKey) as PubSub<ChannelsRecord>; | ||
| } | ||
|
|
||
| /** | ||
| * Subscribe to the channel. | ||
| */ | ||
| subscribe<ChannelKey extends keyof ChannelsRecord>( | ||
| channel: ChannelKey, | ||
| callback: ChannelsRecord[ChannelKey] | ||
| ): void { | ||
| if (!this.channels.has(channel)) { | ||
| this.channels.set(channel, new Set()); | ||
| } | ||
| (this.channels.get(channel) as Set<ChannelsRecord[ChannelKey]>).add(callback); | ||
| } | ||
|
|
||
| /** | ||
| * Unsubscribe from the channel. | ||
| */ | ||
| unsubscribe<ChannelKey extends keyof ChannelsRecord>( | ||
| channel: ChannelKey, | ||
| callback: ChannelsRecord[ChannelKey] | ||
| ): void { | ||
| const channelSet = this.channels.get(channel); | ||
| if (channelSet) { | ||
| channelSet.delete(callback); | ||
| if (channelSet.size === 0) { | ||
| this.channels.delete(channel); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Publishing to the channel. | ||
| */ | ||
| publish<ChannelKey extends keyof ChannelsRecord>( | ||
| channel: ChannelKey, | ||
| data?: TChannelData<ChannelsRecord, ChannelKey> | ||
| ): void { | ||
| const channelSet = this.channels.get(channel); | ||
| if (channelSet) { | ||
| for (const callback of channelSet) { | ||
| callback(data); | ||
| } | ||
| } else { | ||
| console.warn(`No subscribers for channel: ${channel as string}`); | ||
| } | ||
| } | ||
|
|
||
| async publishAsync<ChannelKey extends keyof ChannelsRecord>( | ||
| channel: ChannelKey, | ||
| data?: TChannelData<ChannelsRecord, ChannelKey> | ||
| ): Promise<void> { | ||
| const channelSet = this.channels.get(channel); | ||
| if (channelSet) { | ||
| for (const callback of channelSet) { | ||
| if (callback) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. А на promise не надо ли проверить?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Можно обернуть в промис |
||
| await callback(data); | ||
| } | ||
| } | ||
| } else { | ||
| console.warn(`No subscribers for channel: ${channel as string}`); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Reset all subscriptions. | ||
| */ | ||
| reset(): void { | ||
| this.channels.clear(); | ||
| } | ||
| } | ||
|
|
||
| export default PubSub; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| export type TDefaultChannels = Record<string, (data?: any) => void>; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Пытался, пока не придумал как заменить. Если использовать |
||
|
|
||
| export type TChannelMap<ChannelsRecord extends TDefaultChannels> = Map< | ||
| keyof ChannelsRecord, | ||
| Set<ChannelsRecord[keyof ChannelsRecord]> | ||
| >; | ||
|
|
||
| export type TPubSubInstances = Map<string, unknown>; | ||
|
|
||
| export type TChannelData<ChannelsRecord extends TDefaultChannels, ChannelKey extends keyof ChannelsRecord> = Parameters< | ||
| ChannelsRecord[ChannelKey] | ||
| >[0]; | ||
|
|
||
| export type ChannelsRecordAdapter<T> = { [K in keyof T]: T[K] }; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Зачем адаптер этот нужен? Ты создаешь пакет, у него есть контракт и не надо помогать разработчику выполнять этот контракт. Это допустимо только в случае, когда ты точно знаешь, что есть какой-то существующий популярный контракт и ты его уже адаптируешь.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Чтобы типизировать инстанс сейчас нужно использовать |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "extends": "./tsconfig.json", | ||
| "exclude": ["src/*.tests.ts"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "extends": "../../tsconfig.json", | ||
| "compilerOptions": { | ||
| "declaration": true, | ||
| "declarationDir": "dist", | ||
| "outDir": "dist", | ||
| "module": "commonjs", | ||
| "target": "es6" | ||
| }, | ||
| "include": ["src"] | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Вот этот тип мне кажется проблемой. С ним у нас есть место, которое должно знать о всех событиях, которые надо обрабатывать. Как будто бы появляется лишняя связь между разными частями приложения.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Согласен, с глобальным экземпляром есть такая проблема. Тут можно использовать pub-sub только внутри модуля, если это возможно. Или не типизировать глобальный экземпляр и делать адаптер в каждом модуле.