Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions services/pub-sub/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
src
137 changes: 137 additions & 0 deletions services/pub-sub/README.md
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 = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Вот этот тип мне кажется проблемой. С ним у нас есть место, которое должно знать о всех событиях, которые надо обрабатывать. Как будто бы появляется лишняя связь между разными частями приложения.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Согласен, с глобальным экземпляром есть такая проблема. Тут можно использовать pub-sub только внутри модуля, если это возможно. Или не типизировать глобальный экземпляр и делать адаптер в каждом модуле.

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>>();
```
34 changes: 34 additions & 0 deletions services/pub-sub/package.json
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"
}
}
3 changes: 3 additions & 0 deletions services/pub-sub/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default } from './pubSub';

export type { ChannelsRecordAdapter } from './pubSub.types';
87 changes: 87 additions & 0 deletions services/pub-sub/src/pubSub.tests.ts
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();
});
});
94 changes: 94 additions & 0 deletions services/pub-sub/src/pubSub.ts
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>(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Может не стоит делать сервис синглтоном? Пусть в месте, где этот сервис используется, отдельно решается быть ему синглтоном или нет.

@glebfomin28 glebfomin28 Feb 10, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Можно убрать instances и getInstance.

Я правильно понял, что использоваться должно вот так:

const pubSub1 = new PubSub<{ message: () => void }>()
const pubSub2 = new PubSub<{ message: () => void }>()


pubSub1.subscribe('message', () => {})
pubSub1.publish('message')

pubSub2.subscribe('message', () => {})
pubSub2.publish('message')

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А на promise не надо ли проверить?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Можно обернуть в промис await Promise.resolve(callback(data));

await callback(data);
}
}
} else {
console.warn(`No subscribers for channel: ${channel as string}`);
}
}

/**
* Reset all subscriptions.
*/
reset(): void {
this.channels.clear();
}
}

export default PubSub;
14 changes: 14 additions & 0 deletions services/pub-sub/src/pubSub.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type TDefaultChannels = Record<string, (data?: any) => void>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any заменить на unknown не получилось?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Пытался, пока не придумал как заменить. Если использовать unknown, то не получится типизировать каналы через дженерик.
Будет ошибка
TS2344: Type 'TPubSubInstance' does not satisfy the constraint 'TDefaultChannels'.   Property 'message' is incompatible with index signature.     Type '(msg: string) => void' is not assignable to type '(data?: unknown) => void'.       Types of parameters 'msg' and 'data' are incompatible.         Type 'unknown' is not assignable to type 'string'.


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] };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Зачем адаптер этот нужен? Ты создаешь пакет, у него есть контракт и не надо помогать разработчику выполнять этот контракт. Это допустимо только в случае, когда ты точно знаешь, что есть какой-то существующий популярный контракт и ты его уже адаптируешь.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Чтобы типизировать инстанс сейчас нужно использовать type, а не interface. Иначе TS ругается, что интерфейс не имеет индексной сигнатуры.
Написал об этом в ридми. Добавил ChannelsRecordAdapter на случай если уж необходимо использовать interface для типизации инстанса.

4 changes: 4 additions & 0 deletions services/pub-sub/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["src/*.tests.ts"]
}
11 changes: 11 additions & 0 deletions services/pub-sub/tsconfig.json
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"]
}