Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions services/pub-sub/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
src
119 changes: 119 additions & 0 deletions services/pub-sub/README.md
Original file line number Diff line number Diff line change
@@ -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 = {
Copy link
Member

Choose a reason for hiding this comment

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

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

Copy link
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;
// For async callbacks:
asyncMessage: (data: string) => Promise<void>;
};
```

#### Create an instance
```ts
const pubSubInstance = new PubSub<ChannelsType>();
```

#### 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();
```

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"
}
}
1 change: 1 addition & 0 deletions services/pub-sub/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './pubSub';
113 changes: 113 additions & 0 deletions services/pub-sub/src/pubSub.tests.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
66 changes: 66 additions & 0 deletions services/pub-sub/src/pubSub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
type Callback = (data: unknown) => void;

export default class PubSub {
private events: Map<string, Set<Callback>> = 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<void> {
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) };
});
}
}
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"]
}