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 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!
@@ -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
+ });