@webqit/port-plus 0.1.9 → 0.1.10

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 CHANGED
@@ -18,44 +18,54 @@ npm i @webqit/port-plus
18
18
  ```
19
19
 
20
20
  ```js
21
- import { MessageChannelPlus, BroadcastChannelPlus, WebSocketPort, ... } from '@webqit/port-plus';
21
+ import { MessageChannelPlus, BroadcastChannelPlus, WebSocketPort, StarPort, RelayPort, Observer } from '@webqit/port-plus';
22
+ ```
23
+
24
+ ## CDN Include
25
+
26
+ ```html
27
+ <script src="https://unpkg.com/@webqit/port-plus/dist/main.js"></script>
28
+
29
+ <script>
30
+ const { MessageChannelPlus, BroadcastChannelPlus, WebSocketPort, StarPort, RelayPort, Observer } = window.webqit;
31
+ </script>
22
32
  ```
23
33
 
24
34
  ---
25
35
 
26
36
  ## Design Concepts
27
37
 
28
- Port+ is an API mirror of the Web Messaging APIs built for advanced use cases. An instance of `BroadcastChannelPlus`, for example, gives you the same standard `BroadcastChannel` instance, but better.
38
+ Port+ is an API mirror of the Web Messaging APIs built for advanced use cases. An instance of `BroadcastChannelPlus`, for example, is the same `BroadcastChannel` instance, but one that lets you do more.
29
39
 
30
- The following is the mental model of the existing Web Messaging APIs. The Port+ equivalent comes next.
40
+ To see that changed, here is the existing set of Web Messaging APIs. Next, is the Port+ equivalent.
31
41
 
32
42
  ### (a) The Web's Messaging APIs at a Glance
33
43
 
34
44
  #### 1. MessageChannel
35
45
 
36
46
  ```
37
- MessageChannel (ch)
38
- ├─ ch.port1 ──► MessageEvent (e) ──► e.ports
39
- └─ ch.port2 ──► MessageEvent (e) ──► e.ports
47
+ MessageChannel (mch)
48
+ ├─ mch.port1 ──► MessageEvent (e) ──► e.ports
49
+ └─ mch.port2 ──► MessageEvent (e) ──► e.ports
40
50
  ```
41
51
 
42
- *In this system:*
52
+ *In this structure:*
43
53
 
44
- * `ch.port1` and `ch.port2` are each a message port ([`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort))
54
+ * `mch.port1` and `mch.port2` are each a message port ([`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort))
45
55
  * messages (`e`) arrive as `message` events ([`MessageEvent`](https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent))
46
56
  * `e.ports` are each a message port ([`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort))
47
57
 
48
58
  #### 2. BroadcastChannel
49
59
 
50
60
  ```
51
- BroadcastChannel (br) ──► MessageEvent (e)
61
+ BroadcastChannel (brc) ──► MessageEvent (e)
52
62
  ```
53
63
 
54
- *In this system:*
64
+ *In this structure:*
55
65
 
56
66
  * the `BroadcastChannel` interface is the message port – the equivalent of `MessagePort`
57
67
  * messages (`e`) arrive as `message` events ([`MessageEvent`](https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent))
58
- * no reply ports `e.ports`; not implemented in BroadcastChannel
68
+ * no reply ports; `e.ports` is empty; not implemented in BroadcastChannel
59
69
 
60
70
  #### 3. WebSocket
61
71
 
@@ -63,11 +73,11 @@ BroadcastChannel (br) ──► MessageEvent (e)
63
73
  WebSocket ──► MessageEvent (e)
64
74
  ```
65
75
 
66
- *In this system:*
76
+ *In this structure:*
67
77
 
68
78
  * the `WebSocket` interface is partly a message port (having `addEventListener()`) and partly not (no `postMessage()`)
69
79
  * messages (`e`) arrive as `message` events ([`MessageEvent`](https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent))
70
- * no reply ports `e.ports`; not implemented in WebSocket
80
+ * no reply ports; `e.ports` is empty; not implemented in WebSocket
71
81
  * no API parity with `MessagePort` / `BroadcastChannel` in all
72
82
 
73
83
  ### (b) The Port+ Equivalent
@@ -75,23 +85,45 @@ WebSocket ──► MessageEvent (e)
75
85
  #### 1. MessageChannelPlus
76
86
 
77
87
  ```
78
- MessageChannelPlus (ch)
79
- ├─ ch.port1+ ──► MessageEventPlus (e) ──► e.ports+
80
- └─ ch.port2+ ──► MessageEventPlus (e) ──► e.ports+
88
+ MessageChannelPlus (mch)
89
+ ├─ mch.port1+ ──► MessageEventPlus (e) ──► e.ports+
90
+ └─ mch.port2+ ──► MessageEventPlus (e) ──► e.ports+
81
91
  ```
82
92
 
93
+ *In this structure:*
94
+
95
+ * `mch.port1+` and `mch.port2+` are Port+ interfaces (`MessagePortPlus`)
96
+ * messages arrive as `MessageEventPlus`
97
+ * `e.ports+` recursively expose Port+ interfaces
98
+ * reply ports support advanced features (requests, live objects, relays)
99
+
83
100
  #### 2. BroadcastChannelPlus
84
101
 
85
102
  ```
86
- BroadcastChannelPlus (br) ──► MessageEventPlus (e) ──► e.ports+
103
+ BroadcastChannelPlus (brc) ──► MessageEventPlus (e) ──► e.ports+
87
104
  ```
88
105
 
106
+ *In this structure:*
107
+
108
+ * `BroadcastChannelPlus` acts as a full Port+ interface
109
+ * messages arrive as `MessageEventPlus`
110
+ * `e.ports+` enables reply channels where native BroadcastChannel does not
111
+ * broadcast semantics are preserved while extending capabilities
112
+
89
113
  #### 3. WebSocketPort (WebSocket)
90
114
 
91
115
  ```
92
116
  WebSocketPort ──► MessageEventPlus (e) ──► e.ports+
93
117
  ```
94
118
 
119
+ *In this structure:*
120
+
121
+ * `WebSocketPort` wraps a `WebSocket` as a Port+ interface
122
+ * `postMessage()` replaces ad-hoc `send()` usage
123
+ * messages arrive as `MessageEventPlus`
124
+ * `e.ports+` enables reply channels over WebSockets
125
+ * lifecycle and messaging semantics align with other Port+ interfaces
126
+
95
127
  ### (c) Result
96
128
 
97
129
  **Port+** unifies the messaging model across all three and extends the **port interfaces** and **MessageEvent interface** for advanced use cases.
@@ -102,7 +134,7 @@ General mental model:
102
134
  port+ ──► MessageEventPlus ──► e.ports+
103
135
  ```
104
136
 
105
- Meaning: Port+ interfaces emit `MessageEventPlus`, which recursively exposes `e.ports` as Port+ interface.
137
+ Meaning: Port+ interfaces emit `MessageEventPlus`, which recursively exposes Port+ interface over at `e.ports`.
106
138
 
107
139
  ---
108
140
 
@@ -113,15 +145,15 @@ Meaning: Port+ interfaces emit `MessageEventPlus`, which recursively exposes `e.
113
145
  | API / Feature | Port+ | Msg. Ports | WS |
114
146
  | :--------------------------------- | :--------------- | :---------------- | :------------ |
115
147
  | `postMessage()` | ✓ (advanced) | ✓ (basic) | ✗ (`send()`) |
148
+ | `postRequest()` | ✓ | ✗ | ✗ |
116
149
  | `addEventListener()` / `onmessage` | ✓ | ✓ | ✓ |
117
- | `close()` | ✓ | | |
150
+ | `addRequestListener()` | ✓ | | |
118
151
  | `readyState` | ✓ | ✗ | ✓ |
119
152
  | `readyStateChange()` | ✓ | ✗ | ✗ |
120
- | `postRequest()` | ✓ | ✗ | ✗ |
121
- | `handleRequests()` | ✓ | ✗ | ✗ |
122
153
  | `relay()` | ✓ | ✗ | ✗ |
123
154
  | `channel()` | ✓ | ✗ | ✗ |
124
155
  | `projectMutations()` | ✓ | ✗ | ✗ |
156
+ | `close()` | ✓ | ✓ | ✓ |
125
157
  | `Live Objects`** | ✓ | ✗ | ✗ |
126
158
 
127
159
  *In this table:*
@@ -131,7 +163,7 @@ Meaning: Port+ interfaces emit `MessageEventPlus`, which recursively exposes `e.
131
163
  * **WS** → `WebSocket`
132
164
  * **`**`** → All-new concept
133
165
 
134
- ### 2. Message-Level API
166
+ ### 2. Message Event Interface
135
167
 
136
168
  | API / Feature | Port+ | Msg. Event | WS |
137
169
  | :--------------------------- | :----------------------------- | :---------------------------- | :--------------------- |
@@ -161,8 +193,8 @@ Meaning: Port+ interfaces emit `MessageEventPlus`, which recursively exposes `e.
161
193
  The APIs below are the entry points to a Port+-based messaging system.
162
194
 
163
195
  ```js
164
- const ch = new MessageChannelPlus();
165
- const br = new BroadcastChannelPlus('channel-name');
196
+ const mch = new MessageChannelPlus();
197
+ const brc = new BroadcastChannelPlus('channel-name');
166
198
  const soc = new WebSocketPort(url); // or new WebSocketPort(ws)
167
199
  ```
168
200
 
@@ -187,17 +219,943 @@ wss.on('connection', (ws) => {
187
219
  });
188
220
  ```
189
221
 
190
- Whatever the Port+ instance, it always has the same API and set of capabilities. For example, with `WebSocketPort` you get an `event.ports` implementation over web sockets.
222
+ Whatever the port+ type, every Port+ instance exposes the same interface and capabilities. For example, with `WebSocketPort` you get an `event.ports` implementation over web sockets consistent with the rest.
223
+
224
+ All Port+ interfaces also support live state projection, lifecycle coordination, request/response semantics, and routing.
225
+
226
+ ---
227
+
228
+ ## Live State Projection (Live Objects)
229
+
230
+ Port+ extends message passing with the ability to project state across a port connection and keep that state synchronized over time.
231
+
232
+ This capability is referred to as **Live State Projection**, and the projected objects are called **Live Objects**.
233
+
234
+ Live State Projection is established via the same `.postMessage()` API:
235
+
236
+ **Sender:**
237
+
238
+ ```js
239
+ const state = { count: 0 };
240
+
241
+ port.postMessage({ state }, { live: true });
242
+ ```
243
+
244
+ **Receiver:**
245
+
246
+ ```js
247
+ port.addEventListener('message', (e) => {
248
+ if (e.live) console.log('Live object received');
249
+ const { state } = e.data;
250
+ });
251
+ ```
252
+
253
+ In live mode, continuity of the original object is achieved. Every mutation on the sender side automatically converges on the received copy, and those mustations are observable:
254
+
255
+
256
+ **Sender:**
257
+
258
+ ```js
259
+ setInterval(() => {
260
+ Observer.set(state, 'count', state.count + 1;
261
+ }, 1000);
262
+ ```
263
+
264
+ **Receiver:**
265
+
266
+ ```js
267
+ Observer.observe(state, () => {
268
+ console.log(state.count);
269
+ });
270
+ ```
271
+
272
+ ### Projection Semantics and Lifecycle
273
+
274
+ When an object is sent with `{ live: true }`, Port+ establishes a projection with the following behavior:
275
+
276
+ * mutations on the source object are observed using using the Observer API
277
+ * **differential updates** are sent over a private channel; they converge on the same object on the other side
278
+
279
+ Projection is bound to the lifecycle of the port. It begins once the message is delivered and terminates automatically when the port closes. After closure, the target object remains usable but no longer receives updates.
280
+
281
+ ### Explicit Projection via Propagation Channels
282
+
283
+ In some cases, live state must be projected independently of a specific message. Port+ supports this through a `.projectMutations()` API.
284
+
285
+ Instead of deriving identity from a message event, both sides explicitly agree on a shared object identity and a **propagation channel** over which to project mutations.
286
+
287
+ Below, both sides agree on a certain object identity – `'counter'` – and a propagation channel anique to the object: `'counter'`.
288
+
289
+ **Sender**
290
+
291
+ ```js
292
+ const state = { count: 0 };
293
+
294
+ const stop = port.projectMutations({
295
+ from: state,
296
+ to: 'counter'
297
+ });
298
+
299
+ setInterval(() => {
300
+ Observer.set(state, 'count', state.count + 1);
301
+ }, 1000);
302
+ ```
303
+
304
+ **Receiver:**
305
+
306
+ ```js
307
+ const state = {};
308
+
309
+ const stop = port.projectMutations({
310
+ from: 'counter',
311
+ to: state
312
+ });
313
+
314
+ Observer.observe(state, () => {
315
+ console.log(state.count);
316
+ });
317
+ ```
318
+
319
+ In each case, the return value of `projectMutations()` is a cleanup function:
320
+
321
+ ```js
322
+ stop(); // terminates the projection
323
+ ```
324
+
325
+ Calling it stops mutation tracking and synchronization without closing the port.
326
+
327
+ This lower-level API is intended for advanced scenarios where object identity and lifetime are managed outside the messaging system.
328
+
329
+ ### Motivation: Shared Identity and Continuity
330
+
331
+ Live State Projection enables a shared reactive model across execution contexts.
332
+
333
+ Rather than exchanging updated values, both sides operate on corresponding objects that maintain:
334
+
335
+ * **shared identity** — distinct objects representing the same logical entity
336
+ * **continuity** — stable object references over time
337
+ * **deterministic convergence** — ordered, differential mutation application
338
+ * **lifecycle scoping** — synchronization exists only while the port exists
339
+
340
+ This allows state to be treated as persistent and reactive across a messaging boundary, without polling, replacement, or manual reconciliation.
341
+
342
+ ---
343
+
344
+ ## Lifecycles
345
+
346
+ Port+ introduces a unified lifecycle model for all messaging ports.
347
+
348
+ The purpose of this lifecycle is to make *interaction readiness* explicit: to know when there is someone actively listening on the other end of a port, and when that condition begins and ends.
349
+
350
+ Native web messaging APIs do not expose this information consistently. WebSockets expose transport connectivity, but not as to whether the remote side is actually interacting with the connection. Message ports and broadcast channels expose no readiness signal at all.
351
+
352
+ Port+ addresses this by introducing an interaction-based lifecycle that applies uniformly across all port types.
353
+
354
+ ### Lifecycle States
355
+
356
+ Every Port+ instance transitions through four states in its lifetime:
357
+
358
+ - **`connecting`**: The port is being established or is waiting for a connection to be established.
359
+ - **`open`**: The port is ready for interaction.
360
+ - **`closed`**: The port is closed.
361
+
362
+ At any given point in time, a port is in exactly one of these states. This state is exposed via the `.readyState` property.
363
+
364
+ State transitions (observable milestones) can be observed imperatively using `.readyStateChange()`:
365
+
366
+ ```js
367
+ // The port is ready for interaction.
368
+ await port.readyStateChange('open');
369
+
370
+ // The port has sent its first message.
371
+ await port.readyStateChange('messaging');
372
+
373
+ // The port is closed.
374
+ await port.readyStateChange('close');
375
+ ```
376
+
377
+ > [!TIP]
378
+ > The `readyState` property reflects the current state as a descriptive value (e.g. `'closed'`), while `readyStateChange()` listens for lifecycle transitions using event-style names (e.g. `'close'`).
379
+
380
+ ### Ready-State by Interaction
381
+
382
+ In Port+, being "open" for messages is a special phase in the lifecycle model. It is designed to guarantee the readiness of the other end of the port – rather than the readiness of the port itself. A port transitions to this state when it has ascertained that the other end is ready to interact – not just alive. Messages sent at this point are more likely to be read by "someone".
383
+
384
+ To coordinate readiness across ports, each port is designed to self-signify readiness. The other end of the port receives this signal and acknowledges it. On a successful handshake, the port transitions to the `open` state; an `"open"` event is fired.
385
+
386
+ But a port sends a readiness signal on an explicit condition: when `.start()` is called. This is how the calling code says "I'm ready to interact". But by default, ports have an `autoStart` option enabled, which gets the port to automatically start on first interaction with:
387
+
388
+ + `addEventListener()` – including higher level APIs that may trigger it
389
+ + `postMessage()` – including, also, higher level APIs that may trigger it
390
+
391
+ This behaviour is called "Ready-State by Interaction". To switch from this mode to the explicit mode, set `autoStart` to `false`.
392
+
393
+ ```js
394
+ // An example for a BroadcastChannel port
395
+ const port = new BroadcastChannel(channel, { autoStart: false });
396
+
397
+ port.start(); // Explicitly start the port
398
+ ```
399
+
400
+ ### Early Sends and Automatic Queuing
401
+
402
+ Ports may be configured to implicitly await the `open` ready state before sending messages. In this mode, outbound messages are automatically queued until the port is ready.
403
+
404
+ ```js
405
+ // An example for a BroadcastChannel port
406
+ const port = new BroadcastChannel(channel, { postAwaitsOpen: true });
407
+
408
+ port.postMessage('hello'); // queued
409
+ await port.readyStateChange('open');
410
+ // delivered by now
411
+ ```
412
+
413
+ This allows application code to send messages without coordinating startup order.
191
414
 
192
415
  ---
193
416
 
194
- ## Capabilities
417
+ ## Lifecycle by Port Type
418
+
419
+ Each Port+ transport participates in the lifecycle model differently, while exposing the same observable states.
420
+
421
+ ### MessagePortPlus via MessageChannelPlus
422
+
423
+ Message ports follow a symmetric, point-to-point handshake.
424
+
425
+ Each side transitions to the `open` state when:
426
+
427
+ 1. `.start()` is triggered explicitly or by interaction
428
+ 2. an acknowledgment is recieved from the other side
429
+
430
+ The port is closed when either side is closed explicitly via `.close()`. Once either side closes, a signal is sent to the other side that closes it automatically. A `"close"` event is fired in each case, and ready state transitions to `closed`.
431
+
432
+ ### BroadcastChannelPlus
433
+
434
+ Broadcast channels form a many-to-many port topology and require additional coordination to make readiness meaningful.
435
+
436
+ Port+ supports two modes.
437
+
438
+ #### (a) Default (Peer Mode)
439
+
440
+ In default mode, each participant becomes `open` when:
441
+
442
+ 1. `.start()` is triggered explicitly or by interaction
443
+ 2. an acknowledgment is recieved from at least one peer in the shared channel
444
+
445
+ The port is closed when closed explicitly via `.close()`. A `"close"` event is fired in each case, and ready state transitions to `closed`.
446
+
447
+ #### (b) Client / Server Mode
448
+
449
+ While readiness is synchronized in the default mode – as with other port types – termination is not, due to the many-to-many topology.
450
+
451
+ To support use cases that require synchronized termination across participants, Port+ additionally supports a client/server operational model for BroadcastChannels.
452
+
453
+ The client/server model introduces explicit role semantics. Here, a specific participant is selected as the "control" port – the `server` – and the others are assigned a `client` role:
454
+
455
+ ```js
456
+ const server = new BroadcastChannelPlus('room', {
457
+ clientServerMode: 'server'
458
+ });
459
+
460
+ const client1 = new BroadcastChannelPlus('room', {
461
+ clientServerMode: 'client'
462
+ });
463
+
464
+ const client2 = new BroadcastChannelPlus('room', {
465
+ clientServerMode: 'client'
466
+ });
467
+ ```
468
+
469
+ Both server and clients can join the channel in any order, but the server:
470
+
471
+ + maintains a reference to all connected clients
472
+ + automatically closes all clients when closed
473
+ + automatically closes when all clients leave and its `autoClose` setting is enabled
474
+
475
+ By contrast, a client:
476
+
477
+ * closes alone when closed
478
+
479
+ This mode exists because BroadcastChannel’s native semantics do not provide coordinated teardown or authoritative control. Without it, participants cannot reliably know when a session has ended. Client/server mode enables explicit ownership, deterministic shutdown, and presence-aware coordination over a many-to-many transport.
480
+
481
+ ### WebSocketPort
482
+
483
+ The lifecycle of a WebSocket connection is supported by a native ready state system. A webSocket's `.readyState` property, and related events, can already tell when the connection transitions between states. However, it does not solve the same problem as Port+'s Readiness by Interaction model.
484
+
485
+ Port+ therefore lets you have two lifecycle strategies over WebSocketPort:
486
+
487
+ + Transport-driven lifecycle (default)
488
+ + Interaction-driven lifecycle (opt-in)
489
+
490
+ By default, Port+ lets the WebSocket’s native lifecycle be the authoritative lifecycle.
491
+
492
+ In this mode:
493
+
494
+ * WebSocketPort's `open` and `closed` states are based on the WebSocket's native ready states
495
+ * no handshake control messages are exchanged
496
+ * readiness is assumed once the socket opens
497
+
498
+ #### Explicit Handshake Mode
499
+
500
+ To enable interaction-based readiness, WebSocketPort can opt out of native ready states. This is controlled by the `naturalOpen` flag:
501
+
502
+ ```js
503
+ const port = new WebSocketPort(ws, { naturalOpen: false });
504
+ ```
505
+
506
+ In this mode, each side transitions to the `open` state when:
507
+
508
+ 1. `.start()` is triggered explicitly or by interaction
509
+ 2. an acknowledgment is recieved from the other side
510
+
511
+ This allows WebSocketPort to behave identically to MessagePortPlus and BroadcastChannelPlus with respect to readiness and cleanup.
512
+
513
+ ### Lifecycle Inheritance
514
+
515
+ Ports created via `port.channel()` and ports exposed via `event.ports` inherit:
516
+
517
+ * `autoStart`
518
+ * `postAwaitsOpen`
519
+
520
+ from their parent port.
521
+
522
+ These ports follow a symmetric, point-to-point lifecycle. Closing either side closes the other.
523
+
524
+ ### Practical Use
525
+
526
+ The lifecycle API enables:
527
+
528
+ * safe startup sequencing
529
+ * early message queuing without race conditions
530
+ * deterministic teardown and cleanup
531
+ * consistent readiness checks across transports
532
+
533
+ Port+ makes interaction readiness explicit, observable, and uniform across all messaging primitives.
534
+
535
+ ---
536
+
537
+ ## Composition and Topology
538
+
539
+ Port+ is not limited to point-to-point messaging. Ports can be composed into higher-level structures that define how messages flow, where they propagate, and which connections participate.
540
+
541
+ ### StarPort
542
+
543
+ A `StarPort` is a **fan-in / fan-out proxy** over multiple ports.
544
+
545
+ It acts as a central aggregation point where:
546
+
547
+ * messages *received* by child ports bubble up to the star
548
+ * messages *sent* by the star fan out to all child ports
549
+
550
+ ```js
551
+ const star = new StarPort();
552
+
553
+ star.addPort(port1);
554
+ star.addPort(port2);
555
+ star.addPort(port3);
556
+ ```
557
+
558
+ #### Message Flow Semantics
559
+
560
+ ##### Inbound (Bubbling)
561
+
562
+ As with every port, when a connected port receives a message from its remote peer, it receives a `MessageEventPlus`. Internally that comes as an event dispatched on the port:
563
+
564
+ ```js
565
+ port1.dispatchEvent(new MessageEventPlus(data));
566
+ ```
567
+
568
+ The event:
569
+
570
+ 1. is dispatched on `port1`
571
+ 2. **bubbles up** to the `StarPort`; thus, re-dispatched on `star`
572
+
573
+ The star port essentially makes it possible to listen to all messages received by any of its child ports:
574
+
575
+ ```js
576
+ star.addEventListener('message', (e) => {
577
+ // receives messages from any child port
578
+ });
579
+ ```
580
+
581
+ ##### Outbound (Fan-Out)
582
+
583
+ A `.postMessage()` call on the star port is a `.postMessage()` call to all connected ports:
584
+
585
+ ```js
586
+ star.postMessage(data);
587
+ ```
588
+
589
+ This makes `StarPort` a true proxy: a single observable endpoint over many independent ports.
590
+
591
+ #### Lifecycle Behavior
592
+
593
+ * A star port transitions to the `open` ready state when the first child port is added
594
+ * Closed ports are removed automatically
595
+ * A star port transitions to the `closed` ready state when the last child port is removed and `autoClose` is enabled
596
+
597
+ #### Typical Uses
598
+
599
+ * centralized coordination
600
+ * shared state distribution
601
+ * transport-agnostic hubs
602
+
603
+ ### RelayPort
604
+
605
+ A `RelayPort` is a **router** that forwards messages *between sibling ports*.
606
+
607
+ It is an extension of `StarPort` and inherits all of its properties, methods, and behaviors.
608
+
609
+ ```js
610
+ const relay = new RelayPort('room');
611
+
612
+ relay.addPort(port1);
613
+ relay.addPort(port2);
614
+ relay.addPort(port3);
615
+ ```
616
+
617
+ #### Message Flow Semantics
618
+
619
+ ##### Inbound Routing
620
+
621
+ As with every port, when a connected port receives a message from its remote peer, it receives a `MessageEventPlus`. Internally that comes as an event dispatched on the port:
622
+
623
+ ```js
624
+ port1.dispatchEvent(new MessageEventPlus(data));
625
+ ```
626
+
627
+ Bubbling behaviour works as with a star port. In addition, the relay forwards it to **all other connected ports** as a `.postMessage()` call – excluding the originating port.
628
+ Each connected port sees the message as if it were sent directly by its peer.
629
+
630
+ This creates peer-to-peer fan-out.
631
+
632
+ ##### Outbound Broadcast
633
+
634
+ As with a star port, a `.postMessage()` call on the relay port is a `.postMessage()` call to all connected ports:
635
+
636
+ ```js
637
+ relay.postMessage(data);
638
+ ```
639
+
640
+ `RelayPort` has identical outbound behavior to `StarPort`.
641
+
642
+ ##### Join / Leave Signaling
643
+
644
+ When a port joins (via `relay.addPort()`) or leaves (via `relay.removePort()` or via port close), synthetic join/leave messages are routed to peers.
645
+
646
+ This enables presence-aware systems (e.g. chat rooms).
647
+
648
+ #### Typical Uses
649
+
650
+ * chat rooms
651
+ * collaborative sessions
652
+ * event fan-out
653
+ * decoupled peer coordination
654
+
655
+ ### `port.channel()`
656
+
657
+ `port.channel()` is a universal instance method on all port types that creates a **logical sub-port** scoped to a message type or namespace over that port.
658
+
659
+ ```js
660
+ const chat = port.channel('chat');
661
+ const system = port.channel('system');
662
+ ```
663
+
664
+ A channel:
665
+
666
+ * filters inbound messages by the specified namespace (e.g. `chat` above)
667
+ * automatically namespaces outbound messages with the same
668
+
669
+ ```js
670
+ chat.postMessage({ text: 'hello' });
671
+ // Equivalent to:
672
+ chat.postMessage({ text: 'hello' }, { type: 'chat:message' });
673
+ ```
674
+
675
+ ```js
676
+ chat.addEventListener('message', (e) => {
677
+ // receives only 'chat' messages
678
+ });
679
+ // Equivalent to:
680
+ chat.addEventListener('chat:message', (e) => {
681
+ // handle 'chat' messages
682
+ });
683
+ ```
684
+
685
+ Channels are namespaces within the same port.
686
+
687
+ Channels compose naturally with `StarPort` and `RelayPort`.
688
+
689
+ ### `port.relay()`
690
+
691
+ `port.relay()` is a universal instance method on all port types that establishes **explicit routing relationships** between ports.
692
+
693
+ ```js
694
+ portA.relay({
695
+ to: portB,
696
+ channel: 'chat',
697
+ bidirectional: true
698
+ });
699
+ ```
700
+
701
+ This means:
702
+
703
+ * messages received by `portA` on channel `chat`
704
+ → are forwarded to `portB`
705
+ * optionally in both directions
706
+ * respecting lifecycle, and teardown rules – relay relationships are automatically torn down when either port closes
707
+
708
+ Use `port.relay()` to chain ports together transparently.
709
+
710
+ ```js
711
+ // Forward Port A -> Port B
712
+ portA.relay({ to: portB });
713
+
714
+ // Bidirectional A <-> B
715
+ portA.relay({ to: portB, bidirectional: true });
716
+ ```
717
+
718
+ Use `RelayPort` when:
719
+
720
+ * routing is shared across many ports
721
+ * join/leave semantics matter
722
+ * topology is explicit
723
+
724
+ ### Composition and Live Objects
725
+
726
+ Live objects propagate through composition transparently.
727
+
728
+ * events bubble, or route, as defined
729
+ * mutation convergence follows routing paths
730
+ * projection terminates when required links close
731
+
732
+ This allows shared reactive state to exist **across entire topologies**, not just between endpoints.
733
+
734
+ ---
735
+
736
+ ## Messaging Patterns
737
+
738
+ Port+ supports a small number of messaging patterns. These patterns are not separate APIs — they are ways of structuring interactions using the same port abstraction.
739
+
740
+ ### 1. One-Way Signaling
741
+
742
+ Use this pattern when you need to *notify* the other side, without waiting for a response.
743
+
744
+ ```js
745
+ port.postMessage({ op: 'invalidate-cache' });
746
+ ```
747
+
748
+ This is appropriate when:
749
+
750
+ * ordering matters, but acknowledgment does not
751
+ * the sender does not depend on the receiver’s result
752
+ * failure can be handled independently
753
+
754
+ ### 2. Request / Response (RPC-style)
755
+
756
+ Use this pattern when the sender *expects a result* and wants deterministic correlation.
757
+
758
+ ```js
759
+ const result = await port.postRequest({
760
+ op: 'multiply',
761
+ args: [6, 7]
762
+ });
763
+ ```
764
+
765
+ On the receiving side:
766
+
767
+ ```js
768
+ port.addEventListener('request', (e) => {
769
+ if (e.data.op === 'multiply') {
770
+ return e.data.args[0] * e.data.args[1];
771
+ }
772
+ });
773
+ ```
774
+
775
+ This pattern provides:
776
+
777
+ * automatic request correlation
778
+ * promise-based control flow
779
+ * rejection on timeout or port closure
780
+
781
+ Use this for command execution, queries, and remote procedure calls.
782
+
783
+ ### 3. Conversational Reply Ports
784
+
785
+ Some interactions are not single exchanges, but *conversations*.
786
+
787
+ In these cases, Port+ provides **reply ports** — temporary, private ports scoped to a specific message. This is what `.postRequest()` and `.addRequestListener()` do under the hood.
788
+
789
+ ```js
790
+ const messageChannel = new MessageChannelPlus;
791
+
792
+ // Listen on the reply port
793
+ messageChannel.port2.addEventListener('message', (e) => {
794
+ // handle reply
795
+ console.log('reply', e.data);
796
+ });
797
+
798
+ // Send the message with the reply port
799
+ const result = await port.postRequest(
800
+ {
801
+ op: 'multiply',
802
+ args: [6, 7]
803
+ },
804
+ [messageChannel.port1] // Transfer the reply port
805
+ );
806
+ ```
807
+
808
+ On the receiving side:
809
+
810
+ ```js
811
+ port.addEventListener('message', (e) => {
812
+ const reply = e.ports[0];
813
+
814
+ reply.postMessage(e.data.args[0] * e.data.args[1]);
815
+
816
+ // Continue the conversation
817
+ reply.addEventListener('message', (e) => {
818
+ console.log('follow-up:', e.data);
819
+ });
820
+ });
821
+ ```
822
+
823
+ Reply ports:
824
+
825
+ * form a symmetric 1:1 connection
826
+ * inherit lifecycle settings from their parent port
827
+ * close automatically when either side closes
828
+
829
+ Use reply ports when:
830
+
831
+ * responses are multi-step
832
+ * data streams over time
833
+ * isolation from other traffic matters
834
+
835
+ ### 4. Channelled Conversations
836
+
837
+ Use channels to separate independent flows over the same port. Channels also use reply ports under the hood.
838
+
839
+ ```js
840
+ const chat = port.channel('chat');
841
+ const system = port.channel('system');
842
+ ```
843
+
844
+ ```js
845
+ chat.postMessage({ text: 'hello' });
846
+ system.postMessage({ action: 'sync' });
847
+ ```
848
+
849
+ Channels provide:
850
+
851
+ * logical namespacing
852
+ * inbound filtering
853
+ * outbound tagging
854
+
855
+ They do not create new connections and compose naturally with routing and topology.
856
+
857
+ ### 5. Shared Live State
858
+
859
+ Use this pattern when two sides must stay synchronized over time.
860
+
861
+ ```js
862
+ port.postMessage(state, { live: true });
863
+ ```
864
+
865
+ On the receiving side:
866
+
867
+ ```js
868
+ port.addEventListener('message', (e) => {
869
+ if (e.live) {
870
+ Observer.observe(e.data, () => {
871
+ render(e.data);
872
+ });
873
+ }
874
+ });
875
+ ```
876
+
877
+ This pattern enables:
878
+
879
+ * shared identity across contexts
880
+ * differential mutation propagation
881
+ * lifecycle-bound reactivity
882
+
883
+ It is the foundation for collaborative state, projections, and reactive coordination.
884
+
885
+ (Detailed semantics are covered in the Live Objects section.)
886
+
887
+ ---
888
+
889
+ ## API Reference
890
+
891
+ This section defines the **formal API contract** for Port+.
892
+ Conceptual behavior, lifecycle semantics, and usage patterns are documented in earlier sections.
893
+
894
+ 1. `MessagePortPlus` (Base & Concrete Interfaces)
895
+ 2. `MessageEventPlus`
896
+
897
+ ### 1. `MessagePortPlus` (Base & Concrete Interfaces)
898
+
899
+ All Port+ implementations – `MessagePortPlus`, `BroadcastChannelPlus`, `WebSocketPort`, `StarPort`, `RelayPort` – conform to the `MessagePortPlus` interface.
900
+
901
+ ### Port-Specific Constructor Options
902
+
903
+ + `MessageChannelPlus`
904
+ + `BroadcastChannelPlus`
905
+ + `WebSocketPort`
906
+ + `StarPort`
907
+ + `RelayPort`
908
+
909
+ #### `MessageChannelPlus`
910
+
911
+ ```js
912
+ new MessageChannelPlus({
913
+ autoStart?: boolean,
914
+ postAwaitsOpen?: boolean
915
+ });
916
+ ```
917
+
918
+ | Option | Default | Description |
919
+ | ---------------- | ------- | ----------------------------------------------------- |
920
+ | `autoStart` | `true` | Automatically initiate handshake on first interaction |
921
+ | `postAwaitsOpen` | `false` | Queue messages until the port is `open` |
922
+
923
+ #### `BroadcastChannelPlus`
924
+
925
+ ```js
926
+ new BroadcastChannelPlus(name, {
927
+ autoStart?: boolean,
928
+ postAwaitsOpen?: boolean,
929
+ clientServerMode?: 'server' | 'client' | null,
930
+ autoClose?: boolean
931
+ });
932
+ ```
933
+
934
+ | Option | Default | Description |
935
+ | ------------------ | ------- | --------------------------------------------- |
936
+ | `autoStart` | `true` | Begin handshake on first interaction |
937
+ | `postAwaitsOpen` | `false` | Queue messages until readiness |
938
+ | `clientServerMode` | `null` | Can be one of `'server'`, `'client'` or `null` |
939
+ | `autoClose` | `true` | Auto-close server when all clients disconnect |
940
+
941
+ #### `WebSocketPort`
942
+
943
+ ```js
944
+ new WebSocketPort(wsOrUrl, {
945
+ autoStart?: boolean,
946
+ naturalOpen?: boolean,
947
+ postAwaitsOpen?: boolean
948
+ });
949
+ ```
950
+
951
+ | Option | Default | Description |
952
+ | ---------------- | ------- | ------------------------------------ |
953
+ | `autoStart` | `true` | Begin handshake on first interaction |
954
+ | `naturalOpen` | `true` | Use WebSocket transport readiness instead of Port+ interaction-based ready state |
955
+ | `postAwaitsOpen` | `false` | Queue messages until readiness |
956
+
957
+ #### `StarPort`
958
+
959
+ ```js
960
+ new StarPort({
961
+ autoStart?: boolean,
962
+ postAwaitsOpen?: boolean,
963
+ autoClose?: boolean
964
+ });
965
+ ```
966
+
967
+ | Option | Default | Description |
968
+ | ---------------- | ------- | ------------------------------------ |
969
+ | `autoStart` | `true` | Igonred for self. Inherited by `.channel()` ports |
970
+ | `postAwaitsOpen` | `false` | Queue messages until readiness |
971
+ | `autoClose` | `true` | Auto-close server when all clients disconnect |
972
+
973
+ #### `RelayPort`
974
+
975
+ ```js
976
+ new RelayPort(channelSpec?, {
977
+ autoStart?: boolean,
978
+ postAwaitsOpen?: boolean,
979
+ autoClose?: boolean
980
+ });
981
+ ```
982
+
983
+ | Option | Default | Description |
984
+ | ---------------- | ------- | ------------------------------------ |
985
+ | `autoStart` | `true` | Igonred for self. Inherited by `.channel()` ports |
986
+ | `postAwaitsOpen` | `false` | Queue messages until readiness |
987
+ | `autoClose` | `true` | Auto-close server when all clients disconnect |
988
+
989
+ ### Lifecycle API
990
+
991
+ * `start()`
992
+ * `close()`
993
+ * `readyState`
994
+ * `readyStateChange()`
995
+
996
+ #### `start(): void`
997
+
998
+ Explicitly initiates the interaction handshake.
999
+
1000
+ * Required when `autoStart` is `false`
1001
+ * Signals readiness to the remote side
1002
+
1003
+ #### `close(): void`
1004
+
1005
+ Closes the port.
1006
+
1007
+ * Sends a control close signal
1008
+ * Triggers teardown of dependent ports and projections
1009
+ * Transitions `readyState` to `closed`
1010
+
1011
+ #### `readyState: 'connecting' | 'open' | 'closed'`
1012
+
1013
+ Current lifecycle state of the port.
1014
+
1015
+ #### `readyStateChange(state: 'open' | 'close' | 'messaging'): Promise<void>`
1016
+
1017
+ Resolves when the port transitions into the specified state.
1018
+
1019
+ * `'open'`: handshake completed
1020
+ * `'messaging'`: first outbound message sent
1021
+ * `'close'`: port fully closed
1022
+
1023
+ > [!TIP]
1024
+ >
1025
+ > This is the recommended way to listen for port lifecycle changes compared to using `addEventListener('close', ...)`.
1026
+ > While a corresponding `open` and `close` events are dispatched at the same time as the `readyStateChange` promise, those events may also be simulated manually via `dispatchEvent()` or `postMessage()`. By contrast, `readyStateChange` is system-managed.
1027
+ > Code that depends on port lifecycle changes should always rely on `readyState` or `readyStateChange()`.
1028
+
1029
+ ### Messaging API
1030
+
1031
+ + `postMessage()`
1032
+ + `postRequest()`
1033
+ + `addEventListener()`
1034
+ + `addRequestListener()`
1035
+
1036
+ #### `postMessage(data: any, options?): void`
1037
+
1038
+ Sends a message over the port. `options` are:
1039
+
1040
+ | Option | Type | Description |
1041
+ | ---------- | ------- | ----------------------------------------- |
1042
+ | `type` | string | Message event type (default: `'message'`) |
1043
+ | `live` | boolean | Enable live object projection |
1044
+ | `transfer` | Array | Transferable objects |
1045
+
1046
+ #### `postRequest(data: any, options?): Promise<any>`
1047
+
1048
+ Sends a request and awaits a response. `options` are:
1049
+
1050
+ | Option | Type | Description |
1051
+ | --------- | ----------- | ------------------------------------- |
1052
+ | `timeout` | number | Reject if no response within duration |
1053
+ | `signal` | AbortSignal | Abort the request |
1054
+
1055
+ * Rejects automatically if the port closes
1056
+ * Uses a private ephemeral reply channel internally
1057
+
1058
+ #### `addEventListener(type: string, handler, options?): void`
1059
+
1060
+ Registers a handler for a specific message type. `options` are:
1061
+
1062
+ | Option | Type | Description |
1063
+ | --------- | ----------- | ------------------------------------- |
1064
+ | `once` | boolean | Remove listener after first invocation |
1065
+ | `signal` | AbortSignal | Abort the request |
1066
+
1067
+ Receives `MessageEventPlus` instances.
1068
+
1069
+ #### `addRequestListener(type: string, handler, options?): void`
1070
+
1071
+ Registers a request handler for a specific message type. `options` are:
1072
+
1073
+ | Option | Type | Description |
1074
+ | --------- | ----------- | ------------------------------------- |
1075
+ | `once` | boolean | Remove listener after first invocation |
1076
+ | `signal` | AbortSignal | Abort the request |
1077
+
1078
+ * Return value (or resolved promise) is sent as the response
1079
+ * Thrown errors reject the requester
1080
+
1081
+ ### Structuring & Routing
1082
+
1083
+ + `channel()`
1084
+ + `relay()`
1085
+
1086
+ #### `channel(name: string): MessagePortPlus`
1087
+
1088
+ Creates a logical sub-port scoped to a message namespace.
1089
+
1090
+ * Filters inbound messages
1091
+ * Tags outbound messages
1092
+ * Shares lifecycle with the parent port
1093
+
1094
+ Ports created via `port.channel()` (and ports exposed via `event.ports`) inherit:
1095
+
1096
+ * `autoStart`
1097
+ * `postAwaitsOpen`
1098
+
1099
+ from their parent port.
1100
+
1101
+ #### `relay(config): () => void`
1102
+
1103
+ Establishes explicit routing between ports. `config` is:
1104
+
1105
+ | Config Option | Type | Description |
1106
+ | ---------------- | --------------- | ------------------------- |
1107
+ | `to` | MessagePortPlus | Target port |
1108
+ | `channel` | string | object | Channel filter |
1109
+ | `bidirectional` | boolean | Relay in both directions |
1110
+ | `resolveMessage` | function | Transform message payload |
1111
+
1112
+ If `channel` is specified, it may be a string or a `{ from, to }` mapping. Only messages of the specified channel are relayed. If specified as a `from` -> `to` mapping, incoming messages are matched for `from` and outgoing messages are namespaced with `to` – effectively a channel mapping.
1113
+
1114
+ Returns a cleanup function that removes the relay.
1115
+
1116
+ ### Live State Projection
1117
+
1118
+ + `projectMutations()`
1119
+
1120
+ #### `projectMutations(options): () => void`
1121
+
1122
+ Projects mutations between objects across a port. `options` are:
1123
+
1124
+ | Option | Type | Description |
1125
+ | ------ | --------------- | -------------------------- |
1126
+ | `from` | object | string | Source object or shared ID |
1127
+ | `to` | string | object | Target ID or local object |
1128
+
1129
+ * Must be called on **both ends** with complementary `from` / `to`
1130
+ * Returns a function that terminates the projection
1131
+
1132
+ ### 2. `MessageEventPlus`
1133
+
1134
+ All message events dispatched by Port+ ports.
1135
+
1136
+ #### Properties
1137
+
1138
+ | Property | Type | Description |
1139
+ | ------------- | ------------------------------- |
1140
+ | `data` | any | Message payload |
1141
+ | `type` | string | Event type |
1142
+ | `eventID` | string | Stable message identifier |
1143
+ | `ports` | `MessagePortPlus[]` | Reply ports. Each instance inherits `autoStart` and `postAwaitsOpen`. Closing either side closes the other. |
1144
+ | `live` | boolean | Indicates a live object payload |
1145
+ | `relayedFrom` | `MessagePortPlus` | Originating port (if routed) |
1146
+
1147
+ #### Methods
1148
+
1149
+ | Method | Description |
1150
+ | --------------- | ------------------------------- |
1151
+ | `respondWith()` | Sends a response through attached reply ports. `data` and `options` are as is with `postMessage`. |
1152
+
1153
+ #### `respondWith(data, options?): boolean`
1154
+
1155
+ Sends a response through attached reply ports. `data` and `options` are as is with `postMessage`.
1156
+
1157
+ Returns `true` if one or more reply ports were present.
195
1158
 
196
- > **TODO**
197
- > Live Objects
198
- > Lifecycle APIs
199
- > Request / Response Messaging
200
- > Forwarding and Topologies
201
1159
 
202
1160
  ---
203
1161
 
@@ -211,3 +1169,4 @@ MIT.
211
1169
  [bundle-href]: https://bundlephobia.com/result?p=@webqit/port-plus
212
1170
  [license-src]: https://img.shields.io/github/license/webqit/port-plus.svg?style=flat&colorA=18181B&colorB=F0DB4F
213
1171
  [license-href]: https://github.com/webqit/port-plus/blob/master/LICENSE
1172
+