@effect.healthcare/messenger 0.3.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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @module Component
3
+ */
4
+
5
+ /**
6
+ * @category Configuration
7
+ */
8
+ export const Tag = "app-messenger" as const;
9
+
10
+ /**
11
+ * Local storage key under which the session token is stored (and renewed) by
12
+ * the License component. Externalized so the contract lives in one place.
13
+ * @category Configuration
14
+ */
15
+ export const Session = "app-license.session" as const;
16
+
17
+ /**
18
+ * The Azure Web PubSub subprotocol required for structured group messaging.
19
+ * @category Configuration
20
+ */
21
+ export const Protocol = "json.webpubsub.azure.v1" as const;
22
+
23
+ /**
24
+ * Inbound message types the messenger reacts to (the `data.type` of a frame).
25
+ * @category Metadata
26
+ * @enum
27
+ */
28
+ export const Message = {
29
+ MASTER_DATA_CHANGED: "masterData.changed",
30
+ } as const;
31
+
32
+ /**
33
+ * Inbound message types the messenger reacts to.
34
+ * @category Metadata
35
+ */
36
+ export type Message = (typeof Message)[keyof typeof Message];
37
+
38
+ /**
39
+ * HTML Attributes available to set
40
+ * @category Metadata
41
+ * @enum
42
+ */
43
+ export const Attributes = {
44
+ CONNECTION: "connection",
45
+ ENDPOINT: "endpoint",
46
+ } as const;
47
+ /**
48
+ * HTML Attributes available to set
49
+ * @category Metadata
50
+ */
51
+ export type Attributes = typeof Attributes;
52
+
53
+ /**
54
+ * HTML Attributes available to set
55
+ * @category Metadata
56
+ * @enum
57
+ */
58
+ export const State = {
59
+ ...Attributes,
60
+ } as const;
61
+
62
+ /**
63
+ * HTML Attributes available to set
64
+ * @category Metadata
65
+ */
66
+ export type State = (typeof State)[keyof typeof State];
67
+
68
+ /**
69
+ * Live connection status value domain
70
+ * @category Metadata
71
+ * @enum
72
+ */
73
+ export const Connection = {
74
+ DISCONNECTED: "disconnected",
75
+ CONNECTING: "connecting",
76
+ CONNECTED: "connected",
77
+ } as const;
78
+
79
+ /**
80
+ * Live connection status value domain
81
+ * @category Metadata
82
+ */
83
+ export type Connection = (typeof Connection)[keyof typeof Connection];
84
+
85
+ /**
86
+ * The negotiate endpoint URL value domain
87
+ * @category Metadata
88
+ */
89
+ export type Endpoint = string;
90
+
91
+ /**
92
+ * @category Metadata
93
+ * @enum
94
+ */
95
+ export const Operation = {
96
+ CONNECT: "connect",
97
+ DISCONNECT: "disconnect",
98
+ } as const;
99
+
100
+ /**
101
+ * @category Metadata
102
+ */
103
+ export type Operation = (typeof Operation)[keyof typeof Operation];
104
+
105
+ /**
106
+ * @category Metadata
107
+ * @enum
108
+ */
109
+ export const Event = {
110
+ ON_ENDPOINT_CHANGE: "onendpointchange",
111
+ ON_CONNECTING: "onconnecting",
112
+ ON_CONNECTED: "onconnected",
113
+ ON_DISCONNECTED: "ondisconnected",
114
+ ON_MASTERDATACHANGE: "onmasterdatachange",
115
+ } as const;
116
+ /**
117
+ * @category Metadata
118
+ */
119
+ export type Event = (typeof Event)[keyof typeof Event];
120
+
121
+ /**
122
+ * @category Metadata
123
+ * @enum
124
+ */
125
+ export const Gesture = {
126
+ MESSAGE: "message",
127
+ } as const;
128
+ /**
129
+ * @category Metadata
130
+ */
131
+ export type Gesture = (typeof Gesture)[keyof typeof Gesture];
@@ -0,0 +1,347 @@
1
+ /**
2
+ * @module Component
3
+ */
4
+
5
+ // Base Component
6
+ import { Component } from "@scalable.software/component";
7
+ import { type Configuration } from "@scalable.software/component";
8
+
9
+ // Component Metadata
10
+ import { Tag, Attributes, Connection, Event, Gesture } from "./messenger.meta.js";
11
+ import { Session, Protocol, Message } from "./messenger.meta.js";
12
+ import { type Endpoint } from "./messenger.meta.js";
13
+
14
+ // Component Validation
15
+ import { Validate } from "./messenger.validation.js";
16
+
17
+ /**
18
+ * Configuration for the headless component — no template or stylesheet.
19
+ * @category Configuration
20
+ */
21
+ export const configuration: Configuration = {
22
+ url: import.meta.url,
23
+ } as const;
24
+
25
+ /**
26
+ * @category Component
27
+ */
28
+ export class Messenger extends Component {
29
+ /**
30
+ * The tag name of the component
31
+ * @category Configuration
32
+ */
33
+ public static get Tag() {
34
+ return Tag;
35
+ }
36
+
37
+ /**
38
+ * Only attributes defined the Attributes object will be observed in DOM
39
+ * @category State
40
+ */
41
+ public static get Attributes(): Attributes {
42
+ return Attributes;
43
+ }
44
+
45
+ /**
46
+ * The live connection status of the messenger. Internally owned.
47
+ * @category State
48
+ * @hidden
49
+ */
50
+ private _connection: Connection = Connection.DISCONNECTED;
51
+
52
+ /**
53
+ * The negotiate endpoint URL. When absent the messenger stays disconnected.
54
+ * @category State
55
+ * @hidden
56
+ */
57
+ private _endpoint: Endpoint | null = null;
58
+
59
+ /**
60
+ * The live Web PubSub socket. Held so it can be closed and replaced.
61
+ * @category State
62
+ * @hidden
63
+ */
64
+ private _socket: WebSocket | null = null;
65
+
66
+ /**
67
+ * Event subscription handler slots.
68
+ * @category Event
69
+ * @hidden
70
+ */
71
+ private _onendpointchange: EventListener | null = null;
72
+ private _onconnecting: EventListener | null = null;
73
+ private _onconnected: EventListener | null = null;
74
+ private _ondisconnected: EventListener | null = null;
75
+ private _onmasterdatachange: EventListener | null = null;
76
+
77
+ /**
78
+ * Cache element references to improve performance
79
+ * @category State
80
+ * @hidden
81
+ */
82
+ protected elements: {} = {};
83
+
84
+ /**
85
+ * @hidden
86
+ */
87
+ constructor() {
88
+ super(configuration);
89
+ }
90
+
91
+ /**
92
+ * The live connection status of the messenger. Internally owned — readable
93
+ * externally, but only mutable from within (protected setter).
94
+ * @category State
95
+ */
96
+ public get connection(): Connection {
97
+ return this._connection;
98
+ }
99
+
100
+ protected set connection(connection: Connection) {
101
+ // Guard
102
+ if (this._connection === connection) return;
103
+
104
+ // Mutate
105
+ this._connection = connection;
106
+ this.setAttribute(Attributes.CONNECTION, connection);
107
+
108
+ // Dispatch
109
+ const event = { detail: { connection } };
110
+ connection === Connection.CONNECTING &&
111
+ this._dispatchEvent(Event.ON_CONNECTING, event);
112
+ connection === Connection.CONNECTED &&
113
+ this._dispatchEvent(Event.ON_CONNECTED, event);
114
+ connection === Connection.DISCONNECTED &&
115
+ this._dispatchEvent(Event.ON_DISCONNECTED, event);
116
+ }
117
+
118
+ /**
119
+ * The negotiate endpoint URL. When absent the messenger stays disconnected.
120
+ * @category State
121
+ */
122
+ public get endpoint(): Endpoint | null {
123
+ return this._endpoint;
124
+ }
125
+
126
+ public set endpoint(endpoint: Endpoint | null) {
127
+ // Normalize
128
+ endpoint = endpoint ?? null;
129
+
130
+ // Validate
131
+ endpoint = endpoint ? Validate.endpoint(endpoint) : null;
132
+
133
+ // Guard
134
+ if (this._endpoint === endpoint) return;
135
+
136
+ // Mutate
137
+ this._endpoint = endpoint;
138
+ endpoint === null
139
+ ? this.removeAttribute(Attributes.ENDPOINT)
140
+ : this.setAttribute(Attributes.ENDPOINT, endpoint);
141
+
142
+ // Dispatch
143
+ this._dispatchEvent(Event.ON_ENDPOINT_CHANGE, { detail: { endpoint } });
144
+ }
145
+
146
+ /**
147
+ * The session token, read fresh from local storage on every access — never
148
+ * cached — because the License component renews and rewrites the key.
149
+ * @category Operation
150
+ * @hidden
151
+ */
152
+ private get _token(): string | null {
153
+ return localStorage.getItem(Session);
154
+ }
155
+
156
+ /**
157
+ * Negotiates a client access URL from the configured endpoint, authenticating
158
+ * with the session token, then opens the live connection. No-op when the
159
+ * endpoint or token is unavailable, or when already connecting or connected.
160
+ * @category Operation
161
+ */
162
+ public connect = async (): Promise<void> => {
163
+ const token = this._token;
164
+
165
+ // Gate + guard
166
+ if (!this.endpoint || !token) return;
167
+ if (this._connection !== Connection.DISCONNECTED) return;
168
+
169
+ // Establish
170
+ this.connection = Connection.CONNECTING;
171
+ const url = await this._negotiate(token);
172
+ this._socket = this._open(url);
173
+ };
174
+
175
+ /**
176
+ * Closes the live connection.
177
+ * @category Operation
178
+ */
179
+ public disconnect = (): void => {
180
+ this._socket?.close();
181
+ this._socket = null;
182
+ this.connection = Connection.DISCONNECTED;
183
+ };
184
+
185
+ /**
186
+ * Subscribes to the `onendpointchange` event. Assigning replaces the previous
187
+ * handler; assigning `null` removes it.
188
+ * @category Event
189
+ */
190
+ public set onendpointchange(handler: EventListener | null) {
191
+ this._onendpointchange &&
192
+ this.removeEventListener(Event.ON_ENDPOINT_CHANGE, this._onendpointchange);
193
+
194
+ this._onendpointchange = handler;
195
+
196
+ this._onendpointchange &&
197
+ this.addEventListener(Event.ON_ENDPOINT_CHANGE, this._onendpointchange);
198
+ }
199
+
200
+ /**
201
+ * Subscribes to the `onconnecting` event. Assigning replaces the previous
202
+ * handler; assigning `null` removes it.
203
+ * @category Event
204
+ */
205
+ public set onconnecting(handler: EventListener | null) {
206
+ this._onconnecting &&
207
+ this.removeEventListener(Event.ON_CONNECTING, this._onconnecting);
208
+
209
+ this._onconnecting = handler;
210
+
211
+ this._onconnecting &&
212
+ this.addEventListener(Event.ON_CONNECTING, this._onconnecting);
213
+ }
214
+
215
+ /**
216
+ * Subscribes to the `onconnected` event. Assigning replaces the previous
217
+ * handler; assigning `null` removes it.
218
+ * @category Event
219
+ */
220
+ public set onconnected(handler: EventListener | null) {
221
+ this._onconnected &&
222
+ this.removeEventListener(Event.ON_CONNECTED, this._onconnected);
223
+
224
+ this._onconnected = handler;
225
+
226
+ this._onconnected &&
227
+ this.addEventListener(Event.ON_CONNECTED, this._onconnected);
228
+ }
229
+
230
+ /**
231
+ * Subscribes to the `ondisconnected` event. Assigning replaces the previous
232
+ * handler; assigning `null` removes it.
233
+ * @category Event
234
+ */
235
+ public set ondisconnected(handler: EventListener | null) {
236
+ this._ondisconnected &&
237
+ this.removeEventListener(Event.ON_DISCONNECTED, this._ondisconnected);
238
+
239
+ this._ondisconnected = handler;
240
+
241
+ this._ondisconnected &&
242
+ this.addEventListener(Event.ON_DISCONNECTED, this._ondisconnected);
243
+ }
244
+
245
+ /**
246
+ * Subscribes to the `onmasterdatachange` event. Assigning replaces the
247
+ * previous handler; assigning `null` removes it.
248
+ * @category Event
249
+ */
250
+ public set onmasterdatachange(handler: EventListener | null) {
251
+ this._onmasterdatachange &&
252
+ this.removeEventListener(
253
+ Event.ON_MASTERDATACHANGE,
254
+ this._onmasterdatachange,
255
+ );
256
+
257
+ this._onmasterdatachange = handler;
258
+
259
+ this._onmasterdatachange &&
260
+ this.addEventListener(Event.ON_MASTERDATACHANGE, this._onmasterdatachange);
261
+ }
262
+
263
+ /**
264
+ * List operations to perform for selected attributes being observed in the DOM.
265
+ * @category Configuration
266
+ * @hidden
267
+ */
268
+ protected _attributeHandlers = {
269
+ [Attributes.CONNECTION]: (value: Connection) =>
270
+ value !== this._connection &&
271
+ this.setAttribute(Attributes.CONNECTION, this._connection),
272
+ [Attributes.ENDPOINT]: (value: Endpoint) => (this.endpoint = value),
273
+ };
274
+
275
+ /**
276
+ * Cache element references to improve performance
277
+ * @category Configuration
278
+ * @hidden
279
+ */
280
+ protected _cache = () => {};
281
+
282
+ /**
283
+ * Initialize component attributes with default values
284
+ * @category Configuration
285
+ * @hidden
286
+ */
287
+ protected _initialize = () => {
288
+ !this.hasAttribute(Attributes.CONNECTION) &&
289
+ this.setAttribute(Attributes.CONNECTION, this._connection);
290
+ };
291
+
292
+ /**
293
+ * Called by the connectedCallback prototypical method
294
+ * @category Configuration
295
+ * @hidden
296
+ */
297
+ protected _addEventListeners = () => {};
298
+
299
+ /**
300
+ * Called by the disconnectedCallback prototypical method
301
+ * @category Configuration
302
+ * @hidden
303
+ */
304
+ protected _removeEventListeners = () => {};
305
+
306
+ /**
307
+ * Negotiates a short-lived Web PubSub client access URL from the endpoint,
308
+ * authenticating the request with the session token.
309
+ * @category Operation
310
+ * @hidden
311
+ */
312
+ private _negotiate = async (token: string): Promise<string> => {
313
+ const response = await fetch(this.endpoint as string, {
314
+ headers: { Authorization: `Bearer ${token}` },
315
+ });
316
+ const { url } = await response.json();
317
+ return url;
318
+ };
319
+
320
+ /**
321
+ * Opens a Web PubSub WebSocket with the required subprotocol and wires the
322
+ * connection lifecycle handlers.
323
+ * @category Operation
324
+ * @hidden
325
+ */
326
+ private _open = (url: string): WebSocket => {
327
+ const socket = new WebSocket(url, Protocol);
328
+ socket.onopen = () => (this.connection = Connection.CONNECTED);
329
+ socket.onclose = () => (this.connection = Connection.DISCONNECTED);
330
+ socket.addEventListener(Gesture.MESSAGE, this._handleMessage);
331
+ return socket;
332
+ };
333
+
334
+ /**
335
+ * Handles an inbound Web PubSub message (the `message` gesture). Dispatches
336
+ * `onmasterdatachange` when the payload's `data.type` is `masterData.changed`.
337
+ * @category Gesture
338
+ * @hidden
339
+ */
340
+ private _handleMessage = (event: MessageEvent) => {
341
+ const message = JSON.parse(event.data);
342
+ message.data?.type === Message.MASTER_DATA_CHANGED &&
343
+ this._dispatchEvent(Event.ON_MASTERDATACHANGE, {
344
+ detail: { datetime: new Date().toISOString() },
345
+ });
346
+ };
347
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @module Component
3
+ */
4
+
5
+ // Component Metadata
6
+ import { type Endpoint } from "./messenger.meta.js";
7
+
8
+ /**
9
+ * Runtime enforcement of declared value domains.
10
+ * @category Validation
11
+ */
12
+ export class Validate {
13
+ /**
14
+ * Validates the negotiate endpoint URL value domain.
15
+ * @category Validation
16
+ */
17
+ public static endpoint = (value: string) => {
18
+ try {
19
+ new URL(value);
20
+ } catch {
21
+ throw new Error(`Invalid endpoint value: ${value}`);
22
+ }
23
+ return value as Endpoint;
24
+ };
25
+ }
@@ -0,0 +1,8 @@
1
+ export default {
2
+ rootDir: ".", // serve repo root: demo pulls in ../importmap and ./dist via the import map
3
+ port: 3000,
4
+ watch: true, // live-reload on file changes
5
+ // open: "/demo/index.html", // left off: the browser is driven via the chrome-devtools MCP
6
+ // Note: the demo uses relative asset paths, so it must be opened at /demo/index.html
7
+ // (not at "/"). appIndex/history-fallback is intentionally not used here.
8
+ };