@design.estate/dees-comms 1.0.32 → 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.
@@ -1,38 +1,152 @@
1
1
  import * as plugins from './dees-comms.plugins.js';
2
2
 
3
- let BroadcastChannel = globalThis.BroadcastChannel;
4
- if (!BroadcastChannel) {
5
- BroadcastChannel = plugins.BroadCastChannelPolyfill as any;
3
+ export interface IDeesCommsOptions {
4
+ /**
5
+ * The channel this instance talks on. Instances hear each other only when
6
+ * they use the same name, in any tab, frame or worker of the same origin.
7
+ * Defaults to `DeesComms.defaultChannelName`, the channel every instance
8
+ * used before names existed.
9
+ */
10
+ channelName?: string;
11
+ }
12
+
13
+ /**
14
+ * Receives the payload of one typed event, and the event itself.
15
+ */
16
+ export type TDeesCommsEventHandler<T extends plugins.typedrequestInterfaces.ITypedEvent<unknown>> = (
17
+ payloadArg: T['payload'],
18
+ eventArg: T
19
+ ) => void | Promise<void>;
20
+
21
+ export interface IDeesCommsSubscription {
22
+ /**
23
+ * Stops this subscription. An event that is being delivered right now does
24
+ * not reach it any more; calling it again does nothing.
25
+ */
26
+ unsubscribe(): void;
6
27
  }
7
28
 
29
+ /**
30
+ * How a typed event travels on the channel. The envelope keeps it apart from
31
+ * typed requests and their responses, which are routed by the typedrouter.
32
+ */
33
+ interface IDeesCommsEventMessage {
34
+ deesCommsEvent: plugins.typedrequestInterfaces.ITypedEvent<unknown>;
35
+ }
36
+
37
+ type TStoredEventHandler = (
38
+ payloadArg: unknown,
39
+ eventArg: plugins.typedrequestInterfaces.ITypedEvent<unknown>
40
+ ) => void | Promise<void>;
41
+
42
+ /**
43
+ * The part of a BroadcastChannel that DeesComms uses: the platform's own, or
44
+ * the polyfill where the platform has none.
45
+ */
46
+ interface IDeesCommsChannel {
47
+ post(messageArg: unknown): Promise<void>;
48
+ close(): Promise<void>;
49
+ }
50
+
51
+ /**
52
+ * Opens a channel that hands every message it receives to `receiveArg` as the
53
+ * message itself: the platform's BroadcastChannel delivers a MessageEvent, the
54
+ * polyfill delivers the message.
55
+ */
56
+ const openChannel = (
57
+ nameArg: string,
58
+ receiveArg: (messageArg: unknown) => void
59
+ ): IDeesCommsChannel => {
60
+ if (typeof globalThis.BroadcastChannel === 'function') {
61
+ const channel = new globalThis.BroadcastChannel(nameArg);
62
+ channel.onmessage = (eventArg: MessageEvent<unknown>) => {
63
+ receiveArg(eventArg.data);
64
+ };
65
+ return {
66
+ post: async (messageArg) => {
67
+ channel.postMessage(messageArg);
68
+ },
69
+ close: async () => {
70
+ channel.close();
71
+ },
72
+ };
73
+ }
74
+ const channel = new plugins.BroadCastChannelPolyfill<unknown>(nameArg);
75
+ channel.onmessage = (messageArg: unknown) => {
76
+ receiveArg(messageArg);
77
+ };
78
+ return {
79
+ post: (messageArg) => channel.postMessage(messageArg),
80
+ close: () => channel.close(),
81
+ };
82
+ };
83
+
84
+ const isObject = (valueArg: unknown): valueArg is Record<string, unknown> =>
85
+ typeof valueArg === 'object' && valueArg !== null;
86
+
87
+ // `Object.hasOwn` is younger than some of the browsers the polyfill serves
88
+ const hasOwn = (objectArg: Record<string, unknown>, keyArg: string): boolean =>
89
+ Object.prototype.hasOwnProperty.call(objectArg, keyArg);
90
+
91
+ /**
92
+ * The event a received message carries: `null` for a message that is no
93
+ * event, `'malformed'` for an envelope without a well-formed event in it.
94
+ */
95
+ const eventOf = (
96
+ messageArg: unknown
97
+ ): plugins.typedrequestInterfaces.ITypedEvent<unknown> | 'malformed' | null => {
98
+ if (!isObject(messageArg) || !hasOwn(messageArg, 'deesCommsEvent')) {
99
+ return null;
100
+ }
101
+ const event = messageArg.deesCommsEvent;
102
+ if (
103
+ !isObject(event) ||
104
+ typeof event.name !== 'string' ||
105
+ typeof event.uniqueEventId !== 'string' ||
106
+ !hasOwn(event, 'payload')
107
+ ) {
108
+ return 'malformed';
109
+ }
110
+ return { name: event.name, uniqueEventId: event.uniqueEventId, payload: event.payload };
111
+ };
112
+
113
+ const createEventId = (): string => {
114
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
115
+ return Array.from(bytes, (byteArg) => byteArg.toString(16).padStart(2, '0')).join('');
116
+ };
117
+
8
118
  /**
9
119
  * a comm class for client side communication between workers and tabs.
10
120
  */
11
121
  export class DeesComms {
12
- private broadcastChannel = new BroadcastChannel('dees-comms');
13
-
122
+ /**
123
+ * The channel of every instance that is not given a name. Code that talks
124
+ * to other code on it, like a service worker and its pages, keeps working
125
+ * unchanged; an application gives its own messages a name of its own.
126
+ */
127
+ public static readonly defaultChannelName = 'dees-comms';
128
+
129
+ public readonly channelName: string;
130
+ private readonly channel: IDeesCommsChannel;
131
+ private readonly eventHandlers = new Map<string, Set<TStoredEventHandler>>();
132
+ private closed = false;
133
+ private closing: Promise<void> | null = null;
134
+
14
135
  // sending messages
15
136
  public typedrouter = new plugins.typedrequest.TypedRouter();
16
137
  public typedtarget = new plugins.typedrequest.TypedTarget({
17
138
  postMethodWithTypedRouter: async (messageArg) => {
18
- this.postMessage(messageArg);
139
+ await this.postMessage(messageArg);
19
140
  },
20
141
  typedRouterRef: this.typedrouter,
21
142
  });
22
143
 
23
144
  // receiving messages
24
- constructor() {
25
- this.broadcastChannel.onmessage = async (eventArg) => {
26
- const message = (eventArg as any).method ? eventArg : eventArg.data;
27
- // Skip hooks for broadcast-received messages to prevent infinite loops
28
- // when traffic logging hooks are set up globally
29
- const response = await this.typedrouter.routeAndAddResponse(message, {
30
- skipHooks: true,
31
- });
32
- if (response && !response.error) {
33
- this.postMessage(response);
34
- }
35
- };
145
+ constructor(optionsArg: IDeesCommsOptions = {}) {
146
+ this.channelName = optionsArg.channelName ?? DeesComms.defaultChannelName;
147
+ this.channel = openChannel(this.channelName, (messageArg) => {
148
+ void this.receive(messageArg);
149
+ });
36
150
  }
37
151
 
38
152
  /**
@@ -51,7 +165,8 @@ export class DeesComms {
51
165
  public async postMessage<T = plugins.typedrequestInterfaces.ITypedRequest>(
52
166
  messageArg: T
53
167
  ): Promise<void> {
54
- this.broadcastChannel.postMessage(messageArg);
168
+ this.assertOpen();
169
+ await this.channel.post(messageArg);
55
170
  }
56
171
 
57
172
  /**
@@ -65,4 +180,125 @@ export class DeesComms {
65
180
  new plugins.typedrequest.TypedHandler<T>(methodArg, handlerFunction)
66
181
  );
67
182
  }
183
+
184
+ /**
185
+ * Sends a typed event to every other instance on this channel and expects
186
+ * no answer. The instance that sends it does not receive it; any other
187
+ * instance on the same channel does, in this context or another one.
188
+ */
189
+ public async broadcast<T extends plugins.typedrequestInterfaces.ITypedEvent<unknown>>(
190
+ nameArg: T['name'],
191
+ payloadArg: T['payload']
192
+ ): Promise<void> {
193
+ const message: IDeesCommsEventMessage = {
194
+ deesCommsEvent: {
195
+ name: nameArg,
196
+ uniqueEventId: createEventId(),
197
+ payload: payloadArg,
198
+ },
199
+ };
200
+ await this.postMessage(message);
201
+ }
202
+
203
+ /**
204
+ * Calls the handler for every typed event of that name another instance
205
+ * broadcasts on this channel. Handlers run in the order they subscribed; one
206
+ * that throws or rejects is reported and does not keep the others from
207
+ * running.
208
+ */
209
+ public subscribe<T extends plugins.typedrequestInterfaces.ITypedEvent<unknown>>(
210
+ nameArg: T['name'],
211
+ handlerArg: TDeesCommsEventHandler<T>
212
+ ): IDeesCommsSubscription {
213
+ this.assertOpen();
214
+ // the name is the contract: what arrives under it is taken as its type,
215
+ // as a typed handler takes the request of its method
216
+ const handler: TStoredEventHandler = (payloadArg, eventArg) =>
217
+ handlerArg(payloadArg as T['payload'], eventArg as T);
218
+ let handlers = this.eventHandlers.get(nameArg);
219
+ if (!handlers) {
220
+ handlers = new Set();
221
+ this.eventHandlers.set(nameArg, handlers);
222
+ }
223
+ handlers.add(handler);
224
+ return {
225
+ unsubscribe: () => {
226
+ const current = this.eventHandlers.get(nameArg);
227
+ if (!current) {
228
+ return;
229
+ }
230
+ current.delete(handler);
231
+ if (current.size === 0) {
232
+ this.eventHandlers.delete(nameArg);
233
+ }
234
+ },
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Closes the channel. Nothing is received from then on, every subscription
240
+ * ends, and sending, broadcasting or subscribing throws. A typed request
241
+ * still waiting for its response when the channel closes cannot receive it
242
+ * and ends with its own timeout. Closing again does nothing.
243
+ */
244
+ public async close(): Promise<void> {
245
+ if (!this.closing) {
246
+ this.closed = true;
247
+ this.eventHandlers.clear();
248
+ this.closing = this.channel.close();
249
+ }
250
+ await this.closing;
251
+ }
252
+
253
+ private assertOpen(): void {
254
+ if (this.closed) {
255
+ throw new Error(`DeesComms channel "${this.channelName}" is closed`);
256
+ }
257
+ }
258
+
259
+ private async receive(messageArg: unknown): Promise<void> {
260
+ if (this.closed) {
261
+ return;
262
+ }
263
+ const event = eventOf(messageArg);
264
+ if (event === 'malformed') {
265
+ console.log('DeesComms received a malformed event');
266
+ return;
267
+ }
268
+ if (event) {
269
+ this.dispatch(event);
270
+ return;
271
+ }
272
+ // Skip hooks for broadcast-received messages to prevent infinite loops
273
+ // when traffic logging hooks are set up globally. The router validates
274
+ // the shape of what it is given.
275
+ const response = await this.typedrouter.routeAndAddResponse(
276
+ messageArg as plugins.typedrequestInterfaces.ITypedRequest,
277
+ { skipHooks: true }
278
+ );
279
+ if (response && !response.error && !this.closed) {
280
+ await this.postMessage(response);
281
+ }
282
+ }
283
+
284
+ private dispatch(eventArg: plugins.typedrequestInterfaces.ITypedEvent<unknown>): void {
285
+ const handlers = this.eventHandlers.get(eventArg.name);
286
+ if (!handlers) {
287
+ return;
288
+ }
289
+ for (const handler of [...handlers]) {
290
+ // a handler that closed the channel ends the delivery, and one an
291
+ // earlier handler unsubscribed is not called any more
292
+ if (this.closed) {
293
+ return;
294
+ }
295
+ if (!handlers.has(handler)) {
296
+ continue;
297
+ }
298
+ void (async () => handler(eventArg.payload, eventArg))().catch((errorArg: unknown) => {
299
+ // the event's name and the failure, never its payload
300
+ console.error(`DeesComms: a handler of the event "${eventArg.name}" failed`, errorArg);
301
+ });
302
+ }
303
+ }
68
304
  }
package/readme.hints.md DELETED
@@ -1 +0,0 @@
1
-