@holo-js/flux 0.1.8 → 0.2.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.
package/dist/index.d.ts CHANGED
@@ -84,6 +84,15 @@ interface FluxClient<TManifest extends GeneratedBroadcastManifest = GeneratedBro
84
84
  type PusherConnectorOptions = {
85
85
  readonly transport?: 'mock';
86
86
  };
87
+ type HoloWebSocketConnectorOptions = {
88
+ readonly key?: string;
89
+ readonly host?: string;
90
+ readonly port?: number;
91
+ readonly path?: string;
92
+ readonly scheme?: 'http' | 'https' | 'ws' | 'wss';
93
+ readonly authEndpoint?: string;
94
+ readonly configEndpoint?: string;
95
+ };
87
96
  type PusherConnectorDebug = {
88
97
  emitEvent(channel: string, event: string, payload: BroadcastJsonObject): void;
89
98
  emitNotification(channel: string, payload: BroadcastJsonObject): void;
@@ -96,6 +105,7 @@ type ConnectorDebugCarrier = {
96
105
  type SubscriptionRegistry = Map<string, Set<() => void>>;
97
106
  declare function createUnavailableConnector(): FluxConnector;
98
107
  declare function createPusherConnector(options?: PusherConnectorOptions): FluxConnector & ConnectorDebugCarrier;
108
+ declare function createHoloWebSocketConnector(options?: HoloWebSocketConnectorOptions): FluxConnector;
99
109
  declare function createSubscription<TManifest extends GeneratedBroadcastManifest, TChannel extends string>(channelName: TChannel, kind: FluxChannelKind, connector: FluxConnector, registry: SubscriptionRegistry): FluxSubscription<TManifest, TChannel> & {
100
110
  readonly __presenceMembers: () => readonly BroadcastJsonObject[];
101
111
  readonly __onPresenceChange: (callback: (members: readonly BroadcastJsonObject[]) => void) => () => void;
@@ -107,6 +117,7 @@ declare function getFluxClient(): FluxClient;
107
117
  declare function resetFluxClient(): void;
108
118
  declare const flux: FluxClient<GeneratedBroadcastManifest>;
109
119
  declare const fluxInternals: {
120
+ createHoloWebSocketConnector: typeof createHoloWebSocketConnector;
110
121
  createUnavailableConnector: typeof createUnavailableConnector;
111
122
  createPusherConnector: typeof createPusherConnector;
112
123
  createPresenceSubscription: typeof createPresenceSubscription;
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/index.ts
2
+ var WEB_SOCKET_OPEN = 1;
2
3
  function normalizeRequiredString(value, label) {
3
4
  const normalized = value.trim();
4
5
  if (!normalized) {
@@ -210,6 +211,410 @@ function createPusherConnector(options = {}) {
210
211
  }
211
212
  });
212
213
  }
214
+ function parseJsonObject(value) {
215
+ const parsed = JSON.parse(value);
216
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
217
+ return Object.freeze({});
218
+ }
219
+ return parsed;
220
+ }
221
+ function isRecord(value) {
222
+ return !!value && typeof value === "object" && !Array.isArray(value);
223
+ }
224
+ function parseWireData(value) {
225
+ if (typeof value === "string") {
226
+ return parseJsonObject(value);
227
+ }
228
+ if (value && typeof value === "object" && !Array.isArray(value)) {
229
+ return value;
230
+ }
231
+ return Object.freeze({});
232
+ }
233
+ function normalizeOptionalConnectorString(value) {
234
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
235
+ }
236
+ function normalizeOptionalConnectorPort(value) {
237
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
238
+ }
239
+ function normalizeOptionalConnectorScheme(value) {
240
+ if (value === "http" || value === "https" || value === "ws" || value === "wss") {
241
+ return value;
242
+ }
243
+ return void 0;
244
+ }
245
+ function mergeHoloWebSocketConnectorOptions(discovered, explicit) {
246
+ return Object.freeze({
247
+ ...discovered,
248
+ ...typeof explicit.key === "undefined" ? {} : { key: explicit.key },
249
+ ...typeof explicit.host === "undefined" ? {} : { host: explicit.host },
250
+ ...typeof explicit.port === "undefined" ? {} : { port: explicit.port },
251
+ ...typeof explicit.path === "undefined" ? {} : { path: explicit.path },
252
+ ...typeof explicit.scheme === "undefined" ? {} : { scheme: explicit.scheme },
253
+ ...typeof explicit.authEndpoint === "undefined" ? {} : { authEndpoint: explicit.authEndpoint },
254
+ ...typeof explicit.configEndpoint === "undefined" ? {} : { configEndpoint: explicit.configEndpoint }
255
+ });
256
+ }
257
+ function normalizeHoloWebSocketConnectorConfig(value) {
258
+ if (!isRecord(value)) {
259
+ return Object.freeze({});
260
+ }
261
+ return Object.freeze({
262
+ ...normalizeOptionalConnectorString(value.key) ? { key: normalizeOptionalConnectorString(value.key) } : {},
263
+ ...normalizeOptionalConnectorString(value.host) ? { host: normalizeOptionalConnectorString(value.host) } : {},
264
+ ...normalizeOptionalConnectorPort(value.port) ? { port: normalizeOptionalConnectorPort(value.port) } : {},
265
+ ...normalizeOptionalConnectorString(value.path) ? { path: normalizeOptionalConnectorString(value.path) } : {},
266
+ ...normalizeOptionalConnectorScheme(value.scheme) ? { scheme: normalizeOptionalConnectorScheme(value.scheme) } : {},
267
+ ...normalizeOptionalConnectorString(value.authEndpoint) ? { authEndpoint: normalizeOptionalConnectorString(value.authEndpoint) } : {}
268
+ });
269
+ }
270
+ function canDiscoverHoloWebSocketConnectorOptions(globals) {
271
+ return !!globals.fetch && !!globals.location && typeof globals.window !== "undefined";
272
+ }
273
+ function hasExplicitHoloWebSocketConnectorOptions(options) {
274
+ return typeof options.key !== "undefined" || typeof options.host !== "undefined" || typeof options.port !== "undefined" || typeof options.path !== "undefined" || typeof options.scheme !== "undefined" || typeof options.authEndpoint !== "undefined";
275
+ }
276
+ async function resolveHoloWebSocketConnectorOptions(options, globals) {
277
+ if (!canDiscoverHoloWebSocketConnectorOptions(globals)) {
278
+ return options;
279
+ }
280
+ try {
281
+ const response = await globals.fetch(options.configEndpoint ?? "/broadcasting/config", {
282
+ headers: {
283
+ accept: "application/json"
284
+ },
285
+ credentials: "same-origin"
286
+ });
287
+ if (!response.ok) {
288
+ return options;
289
+ }
290
+ return mergeHoloWebSocketConnectorOptions(
291
+ normalizeHoloWebSocketConnectorConfig(await response.json()),
292
+ options
293
+ );
294
+ } catch {
295
+ return options;
296
+ }
297
+ }
298
+ function wireChannelName(kind, name) {
299
+ if (kind === "private") {
300
+ return `private-${name}`;
301
+ }
302
+ if (kind === "presence") {
303
+ return `presence-${name}`;
304
+ }
305
+ return name;
306
+ }
307
+ function resolveWireChannelKey(channel) {
308
+ if (channel.startsWith("private-")) {
309
+ return `private:${channel.slice("private-".length)}`;
310
+ }
311
+ if (channel.startsWith("presence-")) {
312
+ return `presence:${channel.slice("presence-".length)}`;
313
+ }
314
+ return `public:${channel}`;
315
+ }
316
+ function resolveBrowserHost(host, globals) {
317
+ const normalized = host?.trim();
318
+ if (!normalized || normalized === "0.0.0.0" || normalized === "127.0.0.1") {
319
+ return globals.location?.hostname?.trim() || normalized || "127.0.0.1";
320
+ }
321
+ return normalized;
322
+ }
323
+ function resolveWebSocketScheme(scheme, globals) {
324
+ if (scheme === "wss" || scheme === "https") {
325
+ return "wss";
326
+ }
327
+ if (scheme === "ws" || scheme === "http") {
328
+ return "ws";
329
+ }
330
+ return globals.location?.protocol === "https:" ? "wss" : "ws";
331
+ }
332
+ function createHoloWebSocketConnector(options = {}) {
333
+ const channels = /* @__PURE__ */ new Map();
334
+ const statusListeners = /* @__PURE__ */ new Set();
335
+ let status = "idle";
336
+ let socket;
337
+ let connecting;
338
+ let resolvedOptions;
339
+ let socketId;
340
+ const setStatus = (next) => {
341
+ if (status === next) {
342
+ return;
343
+ }
344
+ status = next;
345
+ notifyStatusListeners(statusListeners, status);
346
+ };
347
+ const sendMessage = (message) => {
348
+ if (socket?.readyState === WEB_SOCKET_OPEN) {
349
+ socket.send(JSON.stringify(message));
350
+ }
351
+ };
352
+ const authorizeSubscription = async (state) => {
353
+ if (state.kind === "public") {
354
+ return Object.freeze({});
355
+ }
356
+ const endpoint = resolvedOptions?.authEndpoint;
357
+ if (!endpoint) {
358
+ return Object.freeze({});
359
+ }
360
+ const globals = globalThis;
361
+ if (!globals.fetch) {
362
+ throw new Error("[@holo-js/flux] Private channel authorization requires fetch support in this runtime.");
363
+ }
364
+ const channel = wireChannelName(state.kind, state.name);
365
+ const currentSocketId = normalizeRequiredString(socketId ?? "", "Socket id");
366
+ const response = await globals.fetch(endpoint, {
367
+ method: "POST",
368
+ headers: {
369
+ accept: "application/json",
370
+ "content-type": "application/x-www-form-urlencoded;charset=UTF-8"
371
+ },
372
+ credentials: "same-origin",
373
+ body: new URLSearchParams({
374
+ channel_name: channel,
375
+ socket_id: currentSocketId
376
+ })
377
+ });
378
+ if (!response.ok) {
379
+ throw new Error(`[@holo-js/flux] Channel authorization failed with HTTP ${response.status}.`);
380
+ }
381
+ const body = await response.json();
382
+ if (!isRecord(body) || typeof body.auth !== "string") {
383
+ throw new Error("[@holo-js/flux] Channel authorization response is invalid.");
384
+ }
385
+ return Object.freeze({
386
+ auth: body.auth,
387
+ ...typeof body.channel_data === "string" ? { channel_data: body.channel_data } : {}
388
+ });
389
+ };
390
+ const flushSubscriptions = () => {
391
+ for (const state of channels.values()) {
392
+ if (state.subscribed) {
393
+ continue;
394
+ }
395
+ state.subscribed = true;
396
+ if (state.kind === "public" || !resolvedOptions?.authEndpoint) {
397
+ sendMessage({
398
+ event: "pusher:subscribe",
399
+ data: {
400
+ channel: wireChannelName(state.kind, state.name)
401
+ }
402
+ });
403
+ continue;
404
+ }
405
+ void authorizeSubscription(state).then((auth) => {
406
+ sendMessage({
407
+ event: "pusher:subscribe",
408
+ data: {
409
+ channel: wireChannelName(state.kind, state.name),
410
+ ...auth
411
+ }
412
+ });
413
+ }, () => {
414
+ state.subscribed = false;
415
+ });
416
+ }
417
+ };
418
+ const handleMessage = (event) => {
419
+ if (typeof event.data !== "string") {
420
+ return;
421
+ }
422
+ const message = parseJsonObject(event.data);
423
+ const eventName = typeof message.event === "string" ? message.event : "";
424
+ const channel = typeof message.channel === "string" ? message.channel : void 0;
425
+ const payload = parseWireData(message.data);
426
+ if (eventName === "pusher:connection_established") {
427
+ socketId = typeof payload.socket_id === "string" ? payload.socket_id : void 0;
428
+ flushSubscriptions();
429
+ return;
430
+ }
431
+ if (!channel) {
432
+ return;
433
+ }
434
+ const state = channels.get(resolveWireChannelKey(channel));
435
+ if (!state) {
436
+ return;
437
+ }
438
+ if (eventName === "pusher_internal:subscription_succeeded") {
439
+ const presence = payload.presence;
440
+ if (presence && typeof presence === "object" && !Array.isArray(presence)) {
441
+ const hash = presence.hash;
442
+ state.members = Object.freeze(
443
+ hash && typeof hash === "object" && !Array.isArray(hash) ? Object.values(hash) : []
444
+ );
445
+ for (const callback of state.memberListeners) {
446
+ callback(state.members);
447
+ }
448
+ }
449
+ return;
450
+ }
451
+ if (eventName === "pusher_internal:member_added") {
452
+ const member = parseWireData(payload.member);
453
+ state.members = Object.freeze([...state.members, member]);
454
+ for (const callback of state.memberListeners) {
455
+ callback(state.members);
456
+ }
457
+ return;
458
+ }
459
+ if (eventName === "pusher_internal:member_removed") {
460
+ const member = parseWireData(payload.member);
461
+ const key = presenceMemberKey(member);
462
+ state.members = Object.freeze(state.members.filter((candidate) => presenceMemberKey(candidate) !== key));
463
+ for (const callback of state.memberListeners) {
464
+ callback(state.members);
465
+ }
466
+ return;
467
+ }
468
+ if (eventName.startsWith("client-")) {
469
+ const whisper = eventName.slice("client-".length);
470
+ for (const callback of state.whisperListeners.get(whisper) ?? []) {
471
+ callback(payload);
472
+ }
473
+ return;
474
+ }
475
+ for (const callback of state.eventListeners.get(eventName) ?? []) {
476
+ callback(payload);
477
+ }
478
+ };
479
+ const ensureConnected = async () => {
480
+ const globals = globalThis;
481
+ const WebSocketConstructor = globals.WebSocket;
482
+ if (!WebSocketConstructor || !canDiscoverHoloWebSocketConnectorOptions(globals) && !hasExplicitHoloWebSocketConnectorOptions(options)) {
483
+ return;
484
+ }
485
+ if (socket?.readyState === WEB_SOCKET_OPEN) {
486
+ flushSubscriptions();
487
+ return;
488
+ }
489
+ if (connecting) {
490
+ await connecting;
491
+ return;
492
+ }
493
+ resolvedOptions = canDiscoverHoloWebSocketConnectorOptions(globals) ? await resolveHoloWebSocketConnectorOptions(options, globals) : options;
494
+ const scheme = resolveWebSocketScheme(resolvedOptions.scheme, globals);
495
+ const host = resolveBrowserHost(resolvedOptions.host, globals);
496
+ const port = resolvedOptions.port ?? 8080;
497
+ const path = resolvedOptions.path?.trim() || "/app";
498
+ const key = resolvedOptions.key?.trim() || "app-key";
499
+ const normalizedPath = `/${path.replace(/^\/+|\/+$/g, "")}`;
500
+ const url = `${scheme}://${host}:${port}${normalizedPath}/${encodeURIComponent(key)}`;
501
+ setStatus("connecting");
502
+ connecting = new Promise((resolve, reject) => {
503
+ const nextSocket = new WebSocketConstructor(url);
504
+ socket = nextSocket;
505
+ nextSocket.addEventListener("open", () => {
506
+ setStatus("connected");
507
+ flushSubscriptions();
508
+ resolve();
509
+ });
510
+ nextSocket.addEventListener("message", handleMessage);
511
+ nextSocket.addEventListener("close", () => {
512
+ socket = void 0;
513
+ connecting = void 0;
514
+ for (const state of channels.values()) {
515
+ state.subscribed = false;
516
+ }
517
+ setStatus("disconnected");
518
+ });
519
+ nextSocket.addEventListener("error", () => {
520
+ connecting = void 0;
521
+ setStatus("disconnected");
522
+ reject(new Error("[@holo-js/flux] WebSocket connection failed."));
523
+ });
524
+ }).finally(() => {
525
+ connecting = void 0;
526
+ });
527
+ await connecting;
528
+ };
529
+ const ensureChannel = (name, kind) => {
530
+ const key = `${kind}:${name}`;
531
+ const existing = channels.get(key);
532
+ if (existing) {
533
+ return existing;
534
+ }
535
+ const state = {
536
+ name,
537
+ kind,
538
+ eventListeners: /* @__PURE__ */ new Map(),
539
+ whisperListeners: /* @__PURE__ */ new Map(),
540
+ notificationListeners: /* @__PURE__ */ new Set(),
541
+ memberListeners: /* @__PURE__ */ new Set(),
542
+ members: Object.freeze([]),
543
+ subscribed: false
544
+ };
545
+ channels.set(key, state);
546
+ return state;
547
+ };
548
+ return Object.freeze({
549
+ async connect() {
550
+ await ensureConnected();
551
+ },
552
+ async disconnect() {
553
+ socket?.close();
554
+ socket = void 0;
555
+ connecting = void 0;
556
+ channels.clear();
557
+ setStatus("disconnected");
558
+ },
559
+ getStatus() {
560
+ return status;
561
+ },
562
+ onStatusChange(callback) {
563
+ statusListeners.add(callback);
564
+ return () => {
565
+ statusListeners.delete(callback);
566
+ };
567
+ },
568
+ subscribe(channel, kind) {
569
+ const state = ensureChannel(channel, kind);
570
+ void ensureConnected().catch(() => void 0);
571
+ return Object.freeze({
572
+ name: state.name,
573
+ kind: state.kind,
574
+ get members() {
575
+ return state.members;
576
+ },
577
+ onEvent(event, callback) {
578
+ return addCallback(state.eventListeners, event, callback);
579
+ },
580
+ onMembersChange(callback) {
581
+ state.memberListeners.add(callback);
582
+ return () => {
583
+ state.memberListeners.delete(callback);
584
+ };
585
+ },
586
+ onNotification(callback) {
587
+ state.notificationListeners.add(callback);
588
+ return () => {
589
+ state.notificationListeners.delete(callback);
590
+ };
591
+ },
592
+ onWhisper(name, callback) {
593
+ return addCallback(state.whisperListeners, name, callback);
594
+ },
595
+ async sendWhisper(name, payload) {
596
+ await ensureConnected();
597
+ sendMessage({
598
+ event: `client-${name}`,
599
+ channel: wireChannelName(state.kind, state.name),
600
+ data: payload
601
+ });
602
+ },
603
+ leave() {
604
+ if (state.subscribed) {
605
+ sendMessage({
606
+ event: "pusher:unsubscribe",
607
+ data: {
608
+ channel: wireChannelName(state.kind, state.name)
609
+ }
610
+ });
611
+ }
612
+ channels.delete(`${state.kind}:${state.name}`);
613
+ }
614
+ });
615
+ }
616
+ });
617
+ }
213
618
  function createSubscription(channelName, kind, connector, registry) {
214
619
  const connectorChannel = connector.subscribe(channelName, kind);
215
620
  let active = true;
@@ -450,7 +855,12 @@ function createFluxClient(options = {}) {
450
855
  };
451
856
  return Object.freeze(client);
452
857
  }
453
- var defaultFluxClient = createFluxClient();
858
+ function createDefaultFluxClient() {
859
+ return createFluxClient({
860
+ connector: createHoloWebSocketConnector()
861
+ });
862
+ }
863
+ var defaultFluxClient = createDefaultFluxClient();
454
864
  function configureFluxClient(options) {
455
865
  defaultFluxClient = "channel" in options ? options : createFluxClient(options);
456
866
  return defaultFluxClient;
@@ -459,7 +869,7 @@ function getFluxClient() {
459
869
  return defaultFluxClient;
460
870
  }
461
871
  function resetFluxClient() {
462
- defaultFluxClient = createFluxClient();
872
+ defaultFluxClient = createDefaultFluxClient();
463
873
  }
464
874
  var flux = new Proxy({}, {
465
875
  get(_target, property) {
@@ -473,6 +883,7 @@ var flux = new Proxy({}, {
473
883
  }
474
884
  });
475
885
  var fluxInternals = {
886
+ createHoloWebSocketConnector,
476
887
  createUnavailableConnector,
477
888
  createPusherConnector,
478
889
  createPresenceSubscription,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holo-js/flux",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "Holo-JS Framework - framework-agnostic realtime client skeleton for broadcast and presence",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,7 +23,7 @@
23
23
  "test": "vitest --run"
24
24
  },
25
25
  "dependencies": {
26
- "@holo-js/broadcast": "^0.1.8"
26
+ "@holo-js/broadcast": "^0.2.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.10.2",