From ea7552649434f6055a099ddfe53e056eb20cfafd Mon Sep 17 00:00:00 2001
From: Christoph Reiter <reiter.christoph@gmail.com>
Date: Thu, 14 Nov 2019 13:42:52 +0100
Subject: [PATCH] Add experimental events API plus tests

---
 packages/common/events.js      | 149 +++++++++++++++++++++++++++++++++
 packages/common/test/events.js |  76 +++++++++++++++++
 2 files changed, 225 insertions(+)
 create mode 100644 packages/common/events.js
 create mode 100644 packages/common/test/events.js

diff --git a/packages/common/events.js b/packages/common/events.js
new file mode 100644
index 00000000..fdda0b18
--- /dev/null
+++ b/packages/common/events.js
@@ -0,0 +1,149 @@
+export class EventSubscriber {
+
+    /**
+     * @param {string} eventName The event name used for broadcasting the event
+     * @param {string} requestName The event name used for requesting the event
+     */
+    constructor(eventName, requestName) {
+        this._eventName = eventName;
+        this._requestName = requestName;
+        this._callbacks = [];
+        this._eventHandler = this._eventHandler.bind(this);
+    }
+
+    /**
+     * Register a callback for the event
+     *
+     * @param {Function} callback 
+     */
+    subscribe(callback) {
+        const first = (this._callbacks.length === 0);
+        this._callbacks.push(callback);
+        if (first) {
+            window.addEventListener(this._eventName, this._eventHandler);
+        }
+        this._requestUpdate(callback);
+    }
+
+    /**
+     * Unregister a callback registered through subscribe()
+     * 
+     * @param {Function} callback 
+     */
+    unsubscribe(callback) {
+        const index = this._callbacks.indexOf(callback);
+        if (index == -1)
+            throw new Error("not subscribed");
+        this._callbacks.splice(index, 1);
+        const last = (this._callbacks.length === 0);
+        if (last) {
+            window.removeEventListener(this._eventName, this._eventHandler);
+        }
+    }
+
+    /**
+     * Request an event, might not do anything if there is no emitter.
+     */
+    requestUpdate() {
+        this._requestUpdate((data) => {
+            for (const cb of this._callbacks) {
+                cb(data);
+            }
+        });
+    }
+
+    /**
+     * Returns the event data or throws an error if there is no emitter
+     */
+    async requestData() {
+        return new Promise((resolve, reject) => {
+            const event = new CustomEvent(this._requestName, {
+                bubbles: true,
+                composed: true,
+                cancelable: true,
+                detail: {'callback': resolve}
+            });
+            if (window.dispatchEvent(event)) {
+                reject(new Error("other side not there"));
+            }
+        });
+    }
+
+    _requestUpdate(callback) {
+        const event = new CustomEvent(this._requestName, {
+            bubbles: true,
+            composed: true,
+            detail: {callback: callback}
+        });
+        window.dispatchEvent(event);
+    }
+
+    _eventHandler(event) {
+        const data = event.detail.data;
+        for (const cb of this._callbacks) {
+            cb(data);
+        }
+    }
+}
+
+export class EventEmitter {
+
+    /**
+     * @param {string} eventName The event name used for broadcasting the event
+     * @param {string} requestName The event name used for requesting the event
+     */
+    constructor(eventName, requestName) {
+        this._eventName = eventName;
+        this._requestName = requestName;
+    }
+
+    /**
+     * Register a callback that will be called when a new event is generated.
+     * The callback needs to return an object that is send with the event.
+     * 
+     * @param {Function} callback 
+     */
+    registerCallback(callback) {
+        if (this._getData !== undefined)
+            throw new Error("already registered");
+        this._getData = callback;
+        this._onRequest = (event) => {
+            const callback = event.detail.callback;
+            if (callback === undefined) {
+                return;
+            }
+            event.preventDefault();
+            event.stopImmediatePropagation();
+            callback(this._getData());
+        };
+        window.addEventListener(this._requestName, this._onRequest);
+        this.emit();
+    }
+
+    /**
+     * Unregister a callback that was passed to registerCallback()
+     *
+     * @param {Function} callback 
+     */
+    unregisterCallback(callback) {
+        if (this._getData !== callback)
+            throw new Error("not registered");
+        window.removeEventListener(this._requestName, this._onRequest);
+        delete this._getData;
+        delete this._onRequest;
+    }
+
+    /**
+     * Force emit an event (when something has changed and new data needs to be send out)
+     */
+    emit() {
+        if (this._getData === undefined)
+            throw new Error("no callback registered");
+        const event = new CustomEvent(this._eventName, {
+            bubbles: true,
+            composed: true,
+            detail: { data: this._getData() }
+        });
+        window.dispatchEvent(event);
+    }
+}
\ No newline at end of file
diff --git a/packages/common/test/events.js b/packages/common/test/events.js
new file mode 100644
index 00000000..25933976
--- /dev/null
+++ b/packages/common/test/events.js
@@ -0,0 +1,76 @@
+import {assert} from 'chai';
+import {EventEmitter, EventSubscriber} from '../events.js';
+
+
+const assertThrowsAsync = async (fn, ...args) => {
+    let error;
+    try {
+        await fn();
+    } catch (e) {
+        error = e;
+    }
+
+    return assert.throws(() => {
+        if (error !== undefined)
+            throw error;
+    }, ...args);
+};
+
+
+suite('events', () => {
+    test('emitter basics', () => {
+        const ev = new EventEmitter("foo", "foo-req");
+        assert.throws(ev.emit);
+
+        const cb = () => { return 42; };
+        ev.registerCallback(cb);
+        ev.emit();
+        ev.unregisterCallback(cb);
+        assert.throws(() => {ev.unregisterCallback(cb);});
+        assert.throws(ev.emit);
+    });
+
+    test('sub basics', async () => {
+        const sub = new EventSubscriber("foo", "foo-req");
+        await assertThrowsAsync(async () => { await sub.requestData();}, /not there/);
+
+        const buffer = [];
+        const handler = (data) => {
+            buffer.push(data);
+        };
+        sub.subscribe(handler);
+        sub.subscribe(handler);
+        sub.requestUpdate();
+        sub.unsubscribe(handler);
+        sub.unsubscribe(handler);
+        assert.throws(() => {sub.unsubscribe(handler);});
+        assert.deepEqual(buffer, []);
+    });
+
+    test('both basics', async () => {
+        const sub = new EventSubscriber("foo", "foo-req");
+        const ev = new EventEmitter("foo", "foo-req");
+        let i = 0;
+        const cb = () => { return i++; };
+        ev.registerCallback(cb);
+
+        const buffer = [];
+        const handler = (data) => {
+            buffer.push(data);
+        };
+
+        sub.subscribe(handler);
+        assert.deepEqual(buffer, [1]);
+        sub.requestUpdate();
+        assert.deepEqual(buffer, [1, 2]);
+        sub.requestUpdate();
+        assert.deepEqual(buffer, [1, 2, 3]);
+        ev.emit();
+        assert.deepEqual(buffer, [1, 2, 3, 4]);
+
+        sub.unsubscribe(handler);
+        sub.requestUpdate();
+        assert.deepEqual(buffer, [1, 2, 3, 4]);
+        ev.unregisterCallback(cb);
+    });
+});
-- 
GitLab