Skip to content

Commit 5b999bb

Browse files
authored
Merge pull request #195 from podium-lib/bridge-client
feat: listen to and send messages over the Podium JSON RPC bridge if present
2 parents 34be131 + e1584bc commit 5b999bb

File tree

4 files changed

+201
-5
lines changed

4 files changed

+201
-5
lines changed

README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# @podium/browser
22

3-
This is a client-side library designed to send and receive messages between different podlets in a layout.
3+
This is a client-side library designed to:
4+
5+
- send and receive messages between different podlets in a layout.
6+
- send and receive messages between web and native in a webview.
47

58
## Usage
69

@@ -40,6 +43,36 @@ messageBus.subscribe('search', 'query', (event) => {
4043
});
4144
```
4245

46+
### Send messages between web and native
47+
48+
To send messages between web and native the [`@podium/bridge`](https://github.com/podium-lib/bridge) must be in the document. Typically you include this once in your layout so podlets can assume it's present.
49+
50+
The API is similar as sending messages between podlets. This way you can notify both other podlets and any native code using the same API.
51+
52+
```js
53+
import { MessageBus } from '@podium/browser';
54+
55+
const messageBus = new MessageBus();
56+
57+
// notify of a logout
58+
messageBus.publish('system', 'authentication', null);
59+
```
60+
61+
The `channel` and `topic` parameters are combined to form the JSON RPC 2.0 `"method"` property. In the example above the channel `system` and topic `authentication` are combined to the method `"system/authentication"`.
62+
63+
To listen to messages coming in from native:
64+
65+
```js
66+
import { MessageBus } from '@podium/browser';
67+
68+
const messageBus = new MessageBus();
69+
70+
// listen to the `"system/authentication"` message coming from native
71+
messageBus.subscribe('system', 'authentication', (event) => {
72+
console.log(event.payload);
73+
});
74+
```
75+
4376
## API
4477

4578
### MessageBus

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"types": "tsc --declaration --emitDeclarationOnly"
4040
},
4141
"dependencies": {
42+
"@podium/bridge": "^1.2.2",
4243
"eventemitter3": "4.0.7"
4344
},
4445
"devDependencies": {
@@ -57,6 +58,7 @@
5758
"eslint-config-prettier": "9.1.0",
5859
"eslint-plugin-prettier": "5.1.3",
5960
"globals": "15.0.0",
61+
"jsdom": "24.0.0",
6062
"prettier": "3.2.5",
6163
"rollup": "4.15.0",
6264
"tap": "18.7.2"

src/MessageBus.js

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,20 @@ function getGlobalThis() {
1414
}
1515

1616
/**
17-
* @returns {{ ee: EventEmitter; sink: Sink; }}
17+
* @returns {{ ee: EventEmitter; sink: Sink; bridge?: import("@podium/bridge").PodiumBridge }}
1818
*/
1919
function getGlobalObjects() {
2020
let objs = getGlobalThis()['@podium'];
2121
if (!objs) {
2222
objs = {};
23+
getGlobalThis()['@podium'] = objs;
24+
}
25+
if (!objs.ee) {
2326
objs.ee = new EventEmitter();
27+
}
28+
if (!objs.sink) {
2429
objs.sink = new Sink();
25-
getGlobalThis()['@podium'] = objs;
2630
}
27-
2831
return objs;
2932
}
3033

@@ -35,9 +38,10 @@ function getGlobalObjects() {
3538

3639
export default class MessageBus {
3740
constructor() {
38-
const { ee, sink } = getGlobalObjects();
41+
const { ee, sink, bridge } = getGlobalObjects();
3942
this.ee = ee;
4043
this.sink = sink;
44+
this.bridge = bridge;
4145
}
4246

4347
/**
@@ -78,9 +82,35 @@ export default class MessageBus {
7882
const event = new Event(channel, topic, payload);
7983
this.ee.emit(event.toKey(), event);
8084
this.sink.push(event);
85+
if (this.bridge) {
86+
/** @type {T | T[]} */
87+
let params = payload;
88+
89+
if (typeof payload !== 'undefined') {
90+
// JSON RPC 2.0 requires that params is either an object or an array. Wrap primitives in an an array.
91+
const isPrimitive =
92+
typeof params === 'string' ||
93+
typeof params === 'boolean' ||
94+
typeof params === 'number';
95+
if (isPrimitive) {
96+
params = [payload];
97+
}
98+
}
99+
100+
this.bridge.notification({
101+
method: `${channel}/${topic}`,
102+
params,
103+
});
104+
}
81105
return event;
82106
}
83107

108+
/**
109+
* Saves a reference to the event handlers that wrap the API for @podium/bridge, so we can unsubscribe later.
110+
* @type {Map<MessageHandler<any>, import('@podium/bridge').EventHandler<any>>}
111+
*/
112+
#bridgeMap = new Map();
113+
84114
/**
85115
* Subscribe to messages for a channel and topic.
86116
*
@@ -98,6 +128,28 @@ export default class MessageBus {
98128
* ```
99129
*/
100130
subscribe(channel, topic, listener) {
131+
if (this.bridge) {
132+
// If there's a bridge, add a listener for the channel and topic there
133+
// and translate incoming messages to a @podium/browser Event for the
134+
// same API surface in userland.
135+
136+
/** @type {import('@podium/bridge').EventHandler<T>} */
137+
const bridgeListener = (message) => {
138+
const request =
139+
/** @type {import("@podium/bridge").RpcRequest<T>} */ (
140+
message
141+
);
142+
143+
const event = new Event(channel, topic, request.params);
144+
this.sink.push(event);
145+
listener(event);
146+
};
147+
this.bridge.on(`${channel}/${topic}`, bridgeListener);
148+
149+
// Save a reference to the bridgeListener so we can unsubscribe later.
150+
this.#bridgeMap.set(listener, bridgeListener);
151+
}
152+
101153
this.ee.on(toKey(channel, topic), listener);
102154
}
103155

@@ -123,5 +175,12 @@ export default class MessageBus {
123175
*/
124176
unsubscribe(channel, topic, listener) {
125177
this.ee.off(toKey(channel, topic), listener);
178+
179+
if (this.bridge) {
180+
const bridgeListener = this.#bridgeMap.get(listener);
181+
if (bridgeListener) {
182+
this.bridge.off(`${channel}/${topic}`, bridgeListener);
183+
}
184+
}
126185
}
127186
}

test/Bridge.test.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import tap from 'tap';
2+
import { JSDOM } from 'jsdom';
3+
4+
const html = /* html */ `<!doctype html>
5+
<html>
6+
<head>
7+
<meta charset="utf-8">
8+
</head>
9+
<body></body>
10+
</html>`;
11+
const dom = new JSDOM(html);
12+
globalThis.window = dom.window;
13+
14+
const { PodiumBridge } = await import('@podium/bridge');
15+
const { default: MessageBus } = await import('../src/MessageBus.js');
16+
17+
/** @type {import('../src/MessageBus.js').default} */
18+
let bus;
19+
20+
tap.beforeEach(() => {
21+
// Unregister listeners and clear the global between tests for a clean slate
22+
if (globalThis.window['@podium']?.bridge) {
23+
/** @type {import('@podium/bridge').PodiumBridge} */
24+
const bridge = globalThis.window['@podium'].bridge;
25+
bridge.destroy();
26+
}
27+
28+
globalThis.window['@podium'] = {};
29+
globalThis.window['@podium'].bridge = new PodiumBridge();
30+
bus = new MessageBus();
31+
});
32+
33+
tap.test('bridge.on() - should invoke subscribed messagebus listener', (t) => {
34+
t.ok(
35+
globalThis.window['@podium'].bridge,
36+
'Expected the Podium bridge to be globally available',
37+
);
38+
39+
const payload = { a: 'b' };
40+
bus.subscribe('foo', 'bar', (event) => {
41+
t.equal(event.payload, payload);
42+
t.end();
43+
});
44+
45+
const rpcRequest = {
46+
method: 'foo/bar',
47+
params: payload,
48+
jsonrpc: '2.0',
49+
};
50+
51+
globalThis.window.dispatchEvent(
52+
new globalThis.window.CustomEvent('rpcbridge', {
53+
detail: rpcRequest,
54+
}),
55+
);
56+
});
57+
58+
tap.test('unsubscribe() - should remove subscribed listener', (t) => {
59+
t.ok(
60+
globalThis.window['@podium'].bridge,
61+
'Expected the Podium bridge to be globally available',
62+
);
63+
64+
const payload = { a: 'b' };
65+
let count = 0;
66+
const listener = (event) => {
67+
t.equal(event.payload, payload);
68+
count += 1;
69+
};
70+
71+
bus.subscribe('foo', 'bar', listener);
72+
73+
const rpcRequest = {
74+
method: 'foo/bar',
75+
params: payload,
76+
jsonrpc: '2.0',
77+
};
78+
79+
globalThis.window.dispatchEvent(
80+
new globalThis.window.CustomEvent('rpcbridge', {
81+
detail: rpcRequest,
82+
}),
83+
);
84+
globalThis.window.dispatchEvent(
85+
new globalThis.window.CustomEvent('rpcbridge', {
86+
detail: rpcRequest,
87+
}),
88+
);
89+
90+
t.equal(count, 2);
91+
92+
bus.unsubscribe('foo', 'bar', listener);
93+
94+
globalThis.window.dispatchEvent(
95+
new globalThis.window.CustomEvent('rpcbridge', {
96+
detail: rpcRequest,
97+
}),
98+
);
99+
100+
t.equal(count, 2);
101+
t.end();
102+
});

0 commit comments

Comments
 (0)