@design.estate/dees-comms 1.0.32 → 1.1.1

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,186 @@
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;
27
+ }
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
+ * Whether the open channel keeps its process alive. Only Node.js's channel
51
+ * can hold a process; elsewhere this does nothing.
52
+ */
53
+ holdProcess(holdArg: boolean): void;
54
+ }
55
+
56
+ /**
57
+ * A channel that decides whether it keeps its process alive, as Node.js's
58
+ * BroadcastChannel does. A browser's channel has neither method.
59
+ */
60
+ interface IProcessHoldingChannel {
61
+ ref(): void;
62
+ unref(): void;
6
63
  }
7
64
 
65
+ const isProcessHolding = (channelArg: object): channelArg is IProcessHoldingChannel =>
66
+ 'ref' in channelArg &&
67
+ typeof channelArg.ref === 'function' &&
68
+ 'unref' in channelArg &&
69
+ typeof channelArg.unref === 'function';
70
+
71
+ /**
72
+ * Opens a channel that hands every message it receives to `receiveArg` as the
73
+ * message itself: the platform's BroadcastChannel delivers a MessageEvent, the
74
+ * polyfill delivers the message.
75
+ */
76
+ const openChannel = (
77
+ nameArg: string,
78
+ receiveArg: (messageArg: unknown) => void
79
+ ): IDeesCommsChannel => {
80
+ if (typeof globalThis.BroadcastChannel === 'function') {
81
+ const channel = new globalThis.BroadcastChannel(nameArg);
82
+ channel.onmessage = (eventArg: MessageEvent<unknown>) => {
83
+ receiveArg(eventArg.data);
84
+ };
85
+ const processHolding = isProcessHolding(channel) ? channel : null;
86
+ // an open channel alone does not keep a Node.js process running
87
+ processHolding?.unref();
88
+ return {
89
+ post: async (messageArg) => {
90
+ channel.postMessage(messageArg);
91
+ },
92
+ close: async () => {
93
+ channel.close();
94
+ },
95
+ holdProcess: (holdArg) => {
96
+ if (holdArg) {
97
+ processHolding?.ref();
98
+ } else {
99
+ processHolding?.unref();
100
+ }
101
+ },
102
+ };
103
+ }
104
+ const channel = new plugins.BroadCastChannelPolyfill<unknown>(nameArg);
105
+ channel.onmessage = (messageArg: unknown) => {
106
+ receiveArg(messageArg);
107
+ };
108
+ return {
109
+ post: (messageArg) => channel.postMessage(messageArg),
110
+ close: () => channel.close(),
111
+ // the polyfill stands in only where the platform has no channel, which
112
+ // Node.js always has
113
+ holdProcess: () => undefined,
114
+ };
115
+ };
116
+
117
+ const isObject = (valueArg: unknown): valueArg is Record<string, unknown> =>
118
+ typeof valueArg === 'object' && valueArg !== null;
119
+
120
+ // `Object.hasOwn` is younger than some of the browsers the polyfill serves
121
+ const hasOwn = (objectArg: Record<string, unknown>, keyArg: string): boolean =>
122
+ Object.prototype.hasOwnProperty.call(objectArg, keyArg);
123
+
124
+ /**
125
+ * The event a received message carries: `null` for a message that is no
126
+ * event, `'malformed'` for an envelope without a well-formed event in it.
127
+ */
128
+ const eventOf = (
129
+ messageArg: unknown
130
+ ): plugins.typedrequestInterfaces.ITypedEvent<unknown> | 'malformed' | null => {
131
+ if (!isObject(messageArg) || !hasOwn(messageArg, 'deesCommsEvent')) {
132
+ return null;
133
+ }
134
+ const event = messageArg.deesCommsEvent;
135
+ if (
136
+ !isObject(event) ||
137
+ typeof event.name !== 'string' ||
138
+ typeof event.uniqueEventId !== 'string' ||
139
+ !hasOwn(event, 'payload')
140
+ ) {
141
+ return 'malformed';
142
+ }
143
+ return { name: event.name, uniqueEventId: event.uniqueEventId, payload: event.payload };
144
+ };
145
+
146
+ const createEventId = (): string => {
147
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
148
+ return Array.from(bytes, (byteArg) => byteArg.toString(16).padStart(2, '0')).join('');
149
+ };
150
+
8
151
  /**
9
152
  * a comm class for client side communication between workers and tabs.
10
153
  */
11
154
  export class DeesComms {
12
- private broadcastChannel = new BroadcastChannel('dees-comms');
13
-
155
+ /**
156
+ * The channel of every instance that is not given a name. Code that talks
157
+ * to other code on it, like a service worker and its pages, keeps working
158
+ * unchanged; an application gives its own messages a name of its own.
159
+ */
160
+ public static readonly defaultChannelName = 'dees-comms';
161
+
162
+ public readonly channelName: string;
163
+ private readonly channel: IDeesCommsChannel;
164
+ private readonly eventHandlers = new Map<string, Set<TStoredEventHandler>>();
165
+ private hasTypedHandlers = false;
166
+ private closed = false;
167
+ private closing: Promise<void> | null = null;
168
+
14
169
  // sending messages
15
170
  public typedrouter = new plugins.typedrequest.TypedRouter();
16
171
  public typedtarget = new plugins.typedrequest.TypedTarget({
17
172
  postMethodWithTypedRouter: async (messageArg) => {
18
- this.postMessage(messageArg);
173
+ await this.postMessage(messageArg);
19
174
  },
20
175
  typedRouterRef: this.typedrouter,
21
176
  });
22
177
 
23
178
  // 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
- };
179
+ constructor(optionsArg: IDeesCommsOptions = {}) {
180
+ this.channelName = optionsArg.channelName ?? DeesComms.defaultChannelName;
181
+ this.channel = openChannel(this.channelName, (messageArg) => {
182
+ void this.receive(messageArg);
183
+ });
36
184
  }
37
185
 
38
186
  /**
@@ -51,7 +199,8 @@ export class DeesComms {
51
199
  public async postMessage<T = plugins.typedrequestInterfaces.ITypedRequest>(
52
200
  messageArg: T
53
201
  ): Promise<void> {
54
- this.broadcastChannel.postMessage(messageArg);
202
+ this.assertOpen();
203
+ await this.channel.post(messageArg);
55
204
  }
56
205
 
57
206
  /**
@@ -64,5 +213,142 @@ export class DeesComms {
64
213
  this.typedrouter.addTypedHandler(
65
214
  new plugins.typedrequest.TypedHandler<T>(methodArg, handlerFunction)
66
215
  );
216
+ this.hasTypedHandlers = true;
217
+ this.holdProcessWhileListened();
218
+ }
219
+
220
+ /**
221
+ * Sends a typed event to every other instance on this channel and expects
222
+ * no answer. The instance that sends it does not receive it; any other
223
+ * instance on the same channel does, in this context or another one.
224
+ */
225
+ public async broadcast<T extends plugins.typedrequestInterfaces.ITypedEvent<unknown>>(
226
+ nameArg: T['name'],
227
+ payloadArg: T['payload']
228
+ ): Promise<void> {
229
+ const message: IDeesCommsEventMessage = {
230
+ deesCommsEvent: {
231
+ name: nameArg,
232
+ uniqueEventId: createEventId(),
233
+ payload: payloadArg,
234
+ },
235
+ };
236
+ await this.postMessage(message);
237
+ }
238
+
239
+ /**
240
+ * Calls the handler for every typed event of that name another instance
241
+ * broadcasts on this channel. Handlers run in the order they subscribed; one
242
+ * that throws or rejects is reported and does not keep the others from
243
+ * running.
244
+ */
245
+ public subscribe<T extends plugins.typedrequestInterfaces.ITypedEvent<unknown>>(
246
+ nameArg: T['name'],
247
+ handlerArg: TDeesCommsEventHandler<T>
248
+ ): IDeesCommsSubscription {
249
+ this.assertOpen();
250
+ // the name is the contract: what arrives under it is taken as its type,
251
+ // as a typed handler takes the request of its method
252
+ const handler: TStoredEventHandler = (payloadArg, eventArg) =>
253
+ handlerArg(payloadArg as T['payload'], eventArg as T);
254
+ let handlers = this.eventHandlers.get(nameArg);
255
+ if (!handlers) {
256
+ handlers = new Set();
257
+ this.eventHandlers.set(nameArg, handlers);
258
+ }
259
+ handlers.add(handler);
260
+ this.holdProcessWhileListened();
261
+ return {
262
+ unsubscribe: () => {
263
+ const current = this.eventHandlers.get(nameArg);
264
+ if (!current) {
265
+ return;
266
+ }
267
+ current.delete(handler);
268
+ if (current.size === 0) {
269
+ this.eventHandlers.delete(nameArg);
270
+ this.holdProcessWhileListened();
271
+ }
272
+ },
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Closes the channel. Nothing is received from then on, every subscription
278
+ * ends, and sending, broadcasting or subscribing throws. A typed request
279
+ * still waiting for its response when the channel closes cannot receive it
280
+ * and ends with its own timeout. Closing again does nothing.
281
+ */
282
+ public async close(): Promise<void> {
283
+ if (!this.closing) {
284
+ this.closed = true;
285
+ this.eventHandlers.clear();
286
+ this.closing = this.channel.close();
287
+ }
288
+ await this.closing;
289
+ }
290
+
291
+ /**
292
+ * In Node.js an instance keeps its process alive while something listens on
293
+ * it, as a MessagePort with a message listener does: a subscription, or a
294
+ * typed handler it created. One nobody listens on lets the process end.
295
+ */
296
+ private holdProcessWhileListened(): void {
297
+ if (this.closed) {
298
+ return;
299
+ }
300
+ this.channel.holdProcess(this.hasTypedHandlers || this.eventHandlers.size > 0);
301
+ }
302
+
303
+ private assertOpen(): void {
304
+ if (this.closed) {
305
+ throw new Error(`DeesComms channel "${this.channelName}" is closed`);
306
+ }
307
+ }
308
+
309
+ private async receive(messageArg: unknown): Promise<void> {
310
+ if (this.closed) {
311
+ return;
312
+ }
313
+ const event = eventOf(messageArg);
314
+ if (event === 'malformed') {
315
+ console.log('DeesComms received a malformed event');
316
+ return;
317
+ }
318
+ if (event) {
319
+ this.dispatch(event);
320
+ return;
321
+ }
322
+ // Skip hooks for broadcast-received messages to prevent infinite loops
323
+ // when traffic logging hooks are set up globally. The router validates
324
+ // the shape of what it is given.
325
+ const response = await this.typedrouter.routeAndAddResponse(
326
+ messageArg as plugins.typedrequestInterfaces.ITypedRequest,
327
+ { skipHooks: true }
328
+ );
329
+ if (response && !response.error && !this.closed) {
330
+ await this.postMessage(response);
331
+ }
332
+ }
333
+
334
+ private dispatch(eventArg: plugins.typedrequestInterfaces.ITypedEvent<unknown>): void {
335
+ const handlers = this.eventHandlers.get(eventArg.name);
336
+ if (!handlers) {
337
+ return;
338
+ }
339
+ for (const handler of [...handlers]) {
340
+ // a handler that closed the channel ends the delivery, and one an
341
+ // earlier handler unsubscribed is not called any more
342
+ if (this.closed) {
343
+ return;
344
+ }
345
+ if (!handlers.has(handler)) {
346
+ continue;
347
+ }
348
+ void (async () => handler(eventArg.payload, eventArg))().catch((errorArg: unknown) => {
349
+ // the event's name and the failure, never its payload
350
+ console.error(`DeesComms: a handler of the event "${eventArg.name}" failed`, errorArg);
351
+ });
352
+ }
67
353
  }
68
354
  }
package/readme.hints.md DELETED
@@ -1 +0,0 @@
1
-