tonka-broadcaster 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +92 -0
- package/package.json +38 -0
- package/test/index.test.ts +116 -0
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Tonka Broadcaster 📡
|
|
2
|
+
|
|
3
|
+
Bring real-time, event-driven capabilities to your frontend application.
|
|
4
|
+
|
|
5
|
+
This package provides a seamless JavaScript client for the **[Tonka Framework](https://clicalmani.github.io/tonka)** broadcast system. Powered by **Mercure Hub**, it allows you to subscribe to public or private channels and listen for backend events in real-time, acting exactly like an echo system for your modern web apps.
|
|
6
|
+
|
|
7
|
+
## ✨ Features
|
|
8
|
+
|
|
9
|
+
* 🔄 **Real-Time Synchronization**: Instantly receive backend updates via Server-Sent Events (SSE).
|
|
10
|
+
* 🔒 **Private Channels**: Built-in support for JWT-authorized private channels.
|
|
11
|
+
* ⚛️ **Framework Agnostic**: Works perfectly with raw JavaScript, React, Vue, or Svelte.
|
|
12
|
+
* 🧠 **Simple API**: Minimalist subscription and event-handling syntax (`.channel().listen()`).
|
|
13
|
+
* ⚡ **Resource Efficient**: Light alternative to WebSockets, powered by native browser capabilities.
|
|
14
|
+
|
|
15
|
+
## 📦 Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install tonka-broadcaster
|
|
19
|
+
# or
|
|
20
|
+
yarn add tonka-broadcaster
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## ⚙️ Setup
|
|
25
|
+
|
|
26
|
+
Ensure your **Tonka** backend is configured to broadcast events via your Mercure Hub (`http://localhost:3000/.well-known/mercure`).
|
|
27
|
+
|
|
28
|
+
Initialize the broadcaster in your frontend entry point (e.g., `app.js` or `main.js`) :
|
|
29
|
+
|
|
30
|
+
```javascript
|
|
31
|
+
import TonkaBroadcaster from 'tonka-broadcaster';
|
|
32
|
+
|
|
33
|
+
const broadcaster = new TonkaBroadcaster({
|
|
34
|
+
hubUrl: 'http://localhost:3000/.well-known/mercure',
|
|
35
|
+
// Required only if you intend to use private channels
|
|
36
|
+
token: () => localStorage.getItem('tonka_jwt_token')
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export default broadcaster;
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 🚀 Usage
|
|
46
|
+
|
|
47
|
+
### 1. Listen to Public Channels
|
|
48
|
+
|
|
49
|
+
Subscribe to a public channel to listen for global real-time events.
|
|
50
|
+
|
|
51
|
+
```javascript
|
|
52
|
+
import broadcaster from './broadcaster';
|
|
53
|
+
|
|
54
|
+
// Subscribe to a public channel
|
|
55
|
+
const channel = broadcaster.channel('notifications');
|
|
56
|
+
|
|
57
|
+
// Listen for a specific event
|
|
58
|
+
channel.listen('NewNotification', (data) => {
|
|
59
|
+
console.log('Notification received:', data.message);
|
|
60
|
+
alert(`New Alert: ${data.title}`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 2. Listen to Private Channels
|
|
66
|
+
|
|
67
|
+
For user-specific data or protected data streams, use private channels. The package automatically attaches your JWT token to authorize the stream.
|
|
68
|
+
|
|
69
|
+
```javascript
|
|
70
|
+
import broadcaster from './broadcaster';
|
|
71
|
+
|
|
72
|
+
// Subscribe to a secure private channel
|
|
73
|
+
broadcaster
|
|
74
|
+
.private(`user.${userId}`)
|
|
75
|
+
.listen('OrderUpdated', (order) => {
|
|
76
|
+
console.log(`Order #${order.id} status is now: ${order.status}`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 3. Unsubscribe & Cleanup
|
|
82
|
+
|
|
83
|
+
Avoid memory leaks in components (like React's `useEffect` or Vue's `onUnmounted`) by unsubscribing when the view is destroyed.
|
|
84
|
+
|
|
85
|
+
```javascript
|
|
86
|
+
// Leave a single channel
|
|
87
|
+
broadcaster.leave('notifications');
|
|
88
|
+
|
|
89
|
+
// Or leave a private channel
|
|
90
|
+
broadcaster.leave(`user.${userId}`);
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
> 💡 **Pro-Tip for Tonka Ecosystem:** Combine `tonka-router` and `tonka-broadcaster` to trigger dynamic redirects or fetch fresh data using named routes directly inside your event listeners!
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface BroadcasterConfig {
|
|
2
|
+
hubUrl: string;
|
|
3
|
+
token?: string | (() => string | null);
|
|
4
|
+
}
|
|
5
|
+
export type EventCallback = (data: any) => void;
|
|
6
|
+
declare class TonkaChannel {
|
|
7
|
+
private hubUrl;
|
|
8
|
+
private channelName;
|
|
9
|
+
private token?;
|
|
10
|
+
private eventSource;
|
|
11
|
+
private listeners;
|
|
12
|
+
constructor(hubUrl: string, channelName: string, token?: string | undefined);
|
|
13
|
+
private connect;
|
|
14
|
+
listen(eventName: string, callback: EventCallback): this;
|
|
15
|
+
close(): void;
|
|
16
|
+
}
|
|
17
|
+
export default class TonkaBroadcaster {
|
|
18
|
+
private hubUrl;
|
|
19
|
+
private tokenResolver?;
|
|
20
|
+
private activeChannels;
|
|
21
|
+
constructor(config: BroadcasterConfig);
|
|
22
|
+
private resolveToken;
|
|
23
|
+
channel(channelName: string): TonkaChannel;
|
|
24
|
+
private(channelName: string): TonkaChannel;
|
|
25
|
+
leave(channelName: string): void;
|
|
26
|
+
}
|
|
27
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
class TonkaChannel {
|
|
2
|
+
constructor(hubUrl, channelName, token) {
|
|
3
|
+
this.hubUrl = hubUrl;
|
|
4
|
+
this.channelName = channelName;
|
|
5
|
+
this.token = token;
|
|
6
|
+
this.eventSource = null;
|
|
7
|
+
this.listeners = new Map();
|
|
8
|
+
this.connect();
|
|
9
|
+
}
|
|
10
|
+
connect() {
|
|
11
|
+
const url = new URL(this.hubUrl);
|
|
12
|
+
const topicUrl = `https://tonka.framework/channels/${this.channelName}`;
|
|
13
|
+
url.searchParams.append('topic', topicUrl);
|
|
14
|
+
if (this.token) {
|
|
15
|
+
url.searchParams.append('authorization', `Bearer ${this.token}`);
|
|
16
|
+
}
|
|
17
|
+
this.eventSource = new EventSource(url.toString());
|
|
18
|
+
this.eventSource.onmessage = (event) => {
|
|
19
|
+
try {
|
|
20
|
+
const payload = JSON.parse(event.data);
|
|
21
|
+
const eventName = payload.event || 'default';
|
|
22
|
+
const data = payload.data || payload;
|
|
23
|
+
const callbacks = this.listeners.get(eventName);
|
|
24
|
+
if (callbacks) {
|
|
25
|
+
callbacks.forEach(callback => callback(data));
|
|
26
|
+
}
|
|
27
|
+
const globalCallbacks = this.listeners.get('*');
|
|
28
|
+
if (globalCallbacks) {
|
|
29
|
+
globalCallbacks.forEach(callback => callback(payload));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
console.error("Error parsing Mercure event:", error);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
this.eventSource.onerror = (err) => {
|
|
37
|
+
console.error(`SSE connection error on channel [${this.channelName}]:`, err);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
listen(eventName, callback) {
|
|
41
|
+
if (!this.listeners.has(eventName)) {
|
|
42
|
+
this.listeners.set(eventName, []);
|
|
43
|
+
}
|
|
44
|
+
this.listeners.get(eventName).push(callback);
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
close() {
|
|
48
|
+
if (this.eventSource) {
|
|
49
|
+
this.eventSource.close();
|
|
50
|
+
this.eventSource = null;
|
|
51
|
+
}
|
|
52
|
+
this.listeners.clear();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export default class TonkaBroadcaster {
|
|
56
|
+
constructor(config) {
|
|
57
|
+
this.activeChannels = new Map();
|
|
58
|
+
this.hubUrl = config.hubUrl;
|
|
59
|
+
this.tokenResolver = config.token;
|
|
60
|
+
}
|
|
61
|
+
resolveToken() {
|
|
62
|
+
if (typeof this.tokenResolver === 'function') {
|
|
63
|
+
return this.tokenResolver() || undefined;
|
|
64
|
+
}
|
|
65
|
+
return this.tokenResolver;
|
|
66
|
+
}
|
|
67
|
+
channel(channelName) {
|
|
68
|
+
if (!this.activeChannels.has(channelName)) {
|
|
69
|
+
const channel = new TonkaChannel(this.hubUrl, channelName);
|
|
70
|
+
this.activeChannels.set(channelName, channel);
|
|
71
|
+
}
|
|
72
|
+
return this.activeChannels.get(channelName);
|
|
73
|
+
}
|
|
74
|
+
private(channelName) {
|
|
75
|
+
const privateName = `private.${channelName}`;
|
|
76
|
+
if (!this.activeChannels.has(privateName)) {
|
|
77
|
+
const token = this.resolveToken();
|
|
78
|
+
const channel = new TonkaChannel(this.hubUrl, privateName, token);
|
|
79
|
+
this.activeChannels.set(privateName, channel);
|
|
80
|
+
}
|
|
81
|
+
return this.activeChannels.set(privateName, this.activeChannels.get(privateName)) && this.activeChannels.get(privateName);
|
|
82
|
+
}
|
|
83
|
+
leave(channelName) {
|
|
84
|
+
const targets = [channelName, `private.${channelName}`];
|
|
85
|
+
targets.forEach(target => {
|
|
86
|
+
if (this.activeChannels.has(target)) {
|
|
87
|
+
this.activeChannels.get(target).close();
|
|
88
|
+
this.activeChannels.delete(target);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tonka-broadcaster",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "A simple package to broadcast events from Tonka.",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"test"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"test": "vitest run"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/clicalmani/tonka-broadcaster.git"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.0.0",
|
|
24
|
+
"vite": "^5.0.0",
|
|
25
|
+
"vitest": "^4.1.8"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"Tonka",
|
|
29
|
+
"Broadcast",
|
|
30
|
+
"Event"
|
|
31
|
+
],
|
|
32
|
+
"author": "clicalmani",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/clicalmani/tonka-broadcaster/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/clicalmani/tonka-broadcaster#readme"
|
|
38
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import TonkaBroadcaster from '../index';
|
|
3
|
+
|
|
4
|
+
// 1. EventSource Mock Creation
|
|
5
|
+
class MockEventSource {
|
|
6
|
+
url: string;
|
|
7
|
+
onmessage: ((event: any) => void) | null = null;
|
|
8
|
+
onerror: ((err: any) => void) | null = null;
|
|
9
|
+
close = vi.fn();
|
|
10
|
+
|
|
11
|
+
constructor(url: string) {
|
|
12
|
+
this.url = url;
|
|
13
|
+
// Store the instance to simulate events within the tests
|
|
14
|
+
MockEventSource.instances.push(this);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static instances: MockEventSource[] = [];
|
|
18
|
+
static emit(data: any) {
|
|
19
|
+
const lastInstance = MockEventSource.instances[MockEventSource.instances.length - 1];
|
|
20
|
+
if (lastInstance && lastInstance.onmessage) {
|
|
21
|
+
lastInstance.onmessage({ data: JSON.stringify(data) });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
static emitError(err: any) {
|
|
25
|
+
const lastInstance = MockEventSource.instances[MockEventSource.instances.length - 1];
|
|
26
|
+
if (lastInstance && lastInstance.onerror) {
|
|
27
|
+
lastInstance.onerror(err);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('TonkaBroadcaster', () => {
|
|
33
|
+
const hubUrl = 'http://localhost:3000/.well-known/mercure';
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
MockEventSource.instances = [];
|
|
37
|
+
// Replace the browser's global EventSource with our Mock
|
|
38
|
+
vi.stubGlobal('EventSource', MockEventSource);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.unstubAllGlobals();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should initialize a public channel with the correct topic', () => {
|
|
46
|
+
const broadcaster = new TonkaBroadcaster({ hubUrl });
|
|
47
|
+
|
|
48
|
+
// Join a channel
|
|
49
|
+
broadcaster.channel('chat');
|
|
50
|
+
|
|
51
|
+
expect(MockEventSource.instances.length).toBe(1);
|
|
52
|
+
const instantiatedUrl = new URL(MockEventSource.instances[0].url);
|
|
53
|
+
|
|
54
|
+
// Verify the target Mercure URL and the Tonka topic
|
|
55
|
+
expect(instantiatedUrl.origin + instantiatedUrl.pathname).toBe(hubUrl);
|
|
56
|
+
expect(instantiatedUrl.searchParams.get('topic')).toBe('https://tonka.framework/channels/chat');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should inject the JWT token into the URL for private channels', () => {
|
|
60
|
+
const token = 'my-secret-jwt-token';
|
|
61
|
+
const broadcaster = new TonkaBroadcaster({
|
|
62
|
+
hubUrl,
|
|
63
|
+
token: () => token // Dynamic resolution
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
broadcaster.private('dashboard');
|
|
67
|
+
|
|
68
|
+
const instantiatedUrl = new URL(MockEventSource.instances[0].url);
|
|
69
|
+
expect(instantiatedUrl.searchParams.get('topic')).toBe('https://tonka.framework/channels/private.dashboard');
|
|
70
|
+
expect(instantiatedUrl.searchParams.get('authorization')).toBe(`Bearer ${token}`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should trigger the correct listener upon receiving a message', () => {
|
|
74
|
+
const broadcaster = new TonkaBroadcaster({ hubUrl });
|
|
75
|
+
const callback = vi.fn();
|
|
76
|
+
|
|
77
|
+
broadcaster.channel('orders').listen('OrderPlaced', callback);
|
|
78
|
+
|
|
79
|
+
// Simulate a message dispatched by the Mercure Hub
|
|
80
|
+
const mockPayload = {
|
|
81
|
+
event: 'OrderPlaced',
|
|
82
|
+
data: { id: 42, total: 1500 }
|
|
83
|
+
};
|
|
84
|
+
MockEventSource.emit(mockPayload);
|
|
85
|
+
|
|
86
|
+
// Verifications
|
|
87
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(callback).toHaveBeenCalledWith({ id: 42, total: 1500 });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should reuse the same connection if subscribing twice to the same channel (Multiton pattern)', () => {
|
|
92
|
+
const broadcaster = new TonkaBroadcaster({ hubUrl });
|
|
93
|
+
|
|
94
|
+
// Two subscriptions to the same channel
|
|
95
|
+
broadcaster.channel('notifications');
|
|
96
|
+
broadcaster.channel('notifications');
|
|
97
|
+
|
|
98
|
+
// Only a single EventSource instance should be created on the network side
|
|
99
|
+
expect(MockEventSource.instances.length).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should cleanly close the network connection when calling leave()', () => {
|
|
103
|
+
const broadcaster = new TonkaBroadcaster({ hubUrl });
|
|
104
|
+
|
|
105
|
+
broadcaster.channel('crypto');
|
|
106
|
+
expect(MockEventSource.instances.length).toBe(1);
|
|
107
|
+
|
|
108
|
+
const currentMockInstance = MockEventSource.instances[0];
|
|
109
|
+
|
|
110
|
+
// Leave the channel
|
|
111
|
+
broadcaster.leave('crypto');
|
|
112
|
+
|
|
113
|
+
// The native close method of EventSource must have been called
|
|
114
|
+
expect(currentMockInstance.close).toHaveBeenCalledTimes(1);
|
|
115
|
+
});
|
|
116
|
+
});
|