@holo-js/flux 0.1.3

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,109 @@
1
+ import { GeneratedBroadcastManifest, BroadcastJsonObject } from '@holo-js/broadcast';
2
+
3
+ type FluxConnectionStatus = 'idle' | 'connecting' | 'connected' | 'disconnected';
4
+ type FluxChannelKind = 'public' | 'private' | 'presence';
5
+ type ManifestEventName<TManifest extends GeneratedBroadcastManifest> = TManifest['events'][number]['name'] & string;
6
+ type ManifestChannelPattern<TManifest extends GeneratedBroadcastManifest> = TManifest['channels'][number]['pattern'] & string;
7
+ type ManifestChannelEntryByPattern<TManifest extends GeneratedBroadcastManifest, TPattern extends string> = Extract<TManifest['channels'][number], {
8
+ pattern: TPattern;
9
+ }>;
10
+ type ManifestPresenceMember<TManifest extends GeneratedBroadcastManifest, TPattern extends string> = Extract<ManifestChannelEntryByPattern<TManifest, TPattern>, {
11
+ member: unknown;
12
+ }> extends {
13
+ member: infer TMember;
14
+ } ? TMember : BroadcastJsonObject;
15
+ type ManifestWhisperName<TManifest extends GeneratedBroadcastManifest, TPattern extends string> = ManifestChannelEntryByPattern<TManifest, TPattern>['whispers'][number] & string;
16
+ type ManifestEventNamesForPattern<TManifest extends GeneratedBroadcastManifest, TPattern extends string> = Extract<TManifest['events'][number], {
17
+ channels: readonly {
18
+ pattern: TPattern;
19
+ }[];
20
+ }>['name'] & string;
21
+ interface FluxClientOptions<TManifest extends GeneratedBroadcastManifest = GeneratedBroadcastManifest> {
22
+ readonly manifest?: TManifest;
23
+ readonly connection?: string;
24
+ readonly connector?: FluxConnector;
25
+ readonly connectorFactory?: (options: FluxClientOptions<TManifest>) => FluxConnector;
26
+ }
27
+ interface FluxListenerControls {
28
+ leaveChannel(): void;
29
+ leave(): void;
30
+ stopListening(): void;
31
+ listen(): FluxListenerControls;
32
+ }
33
+ interface FluxPresenceState<TMember = unknown> {
34
+ readonly members: readonly TMember[];
35
+ }
36
+ interface FluxConnectionControls {
37
+ connect(): Promise<void>;
38
+ disconnect(): Promise<void>;
39
+ getStatus(): FluxConnectionStatus;
40
+ onStatusChange(callback: (status: FluxConnectionStatus) => void): () => void;
41
+ }
42
+ interface FluxConnectorChannel {
43
+ readonly name: string;
44
+ readonly kind: FluxChannelKind;
45
+ readonly members: readonly BroadcastJsonObject[];
46
+ onEvent(event: string, callback: (payload: BroadcastJsonObject) => void): () => void;
47
+ onMembersChange(callback: (members: readonly BroadcastJsonObject[]) => void): () => void;
48
+ onNotification(callback: (payload: BroadcastJsonObject) => void): () => void;
49
+ onWhisper(name: string, callback: (payload: BroadcastJsonObject) => void): () => void;
50
+ sendWhisper(name: string, payload: BroadcastJsonObject): Promise<void>;
51
+ leave(): void;
52
+ }
53
+ interface FluxConnector {
54
+ connect(): Promise<void>;
55
+ disconnect(): Promise<void>;
56
+ getStatus(): FluxConnectionStatus;
57
+ onStatusChange(callback: (status: FluxConnectionStatus) => void): () => void;
58
+ subscribe(channel: string, kind: FluxChannelKind): FluxConnectorChannel;
59
+ }
60
+ interface FluxSubscription<TManifest extends GeneratedBroadcastManifest = GeneratedBroadcastManifest, TChannel extends string = string> extends FluxListenerControls {
61
+ readonly name: TChannel;
62
+ readonly type: FluxChannelKind;
63
+ listen<TEvent extends ManifestEventNamesForPattern<TManifest, TChannel> | ManifestEventName<TManifest>>(event?: TEvent | readonly TEvent[], callback?: (payload: BroadcastJsonObject) => void): FluxSubscription<TManifest, TChannel>;
64
+ notification(callback: (payload: BroadcastJsonObject) => void): FluxSubscription<TManifest, TChannel>;
65
+ listenForWhisper<TWhisper extends ManifestWhisperName<TManifest, TChannel>>(name: TWhisper, callback: (payload: BroadcastJsonObject) => void): FluxSubscription<TManifest, TChannel>;
66
+ whisper<TWhisper extends ManifestWhisperName<TManifest, TChannel>>(name: TWhisper, payload: BroadcastJsonObject): Promise<void>;
67
+ }
68
+ interface FluxPresenceSubscription<TManifest extends GeneratedBroadcastManifest = GeneratedBroadcastManifest, TChannel extends string = string> extends FluxSubscription<TManifest, TChannel>, FluxPresenceState<ManifestPresenceMember<TManifest, TChannel>> {
69
+ }
70
+ interface FluxClient<TManifest extends GeneratedBroadcastManifest = GeneratedBroadcastManifest> extends FluxConnectionControls {
71
+ readonly options: Readonly<FluxClientOptions<TManifest>>;
72
+ readonly status: FluxConnectionStatus;
73
+ channel<TChannel extends ManifestChannelPattern<TManifest> | (string & {})>(name: TChannel): FluxSubscription<TManifest, TChannel>;
74
+ private<TChannel extends ManifestChannelPattern<TManifest> | (string & {})>(name: TChannel): FluxSubscription<TManifest, TChannel>;
75
+ presence<TChannel extends ManifestChannelPattern<TManifest> | (string & {})>(name: TChannel): FluxPresenceSubscription<TManifest, TChannel>;
76
+ }
77
+ type PusherConnectorOptions = {
78
+ readonly transport?: 'mock';
79
+ };
80
+ type PusherConnectorDebug = {
81
+ emitEvent(channel: string, event: string, payload: BroadcastJsonObject): void;
82
+ emitNotification(channel: string, payload: BroadcastJsonObject): void;
83
+ updatePresenceMembers(channel: string, members: readonly BroadcastJsonObject[]): void;
84
+ getJoinedChannels(): readonly string[];
85
+ };
86
+ type ConnectorDebugCarrier = {
87
+ readonly __debug?: PusherConnectorDebug;
88
+ };
89
+ type SubscriptionRegistry = Map<string, Set<() => void>>;
90
+ declare function createUnavailableConnector(): FluxConnector;
91
+ declare function createPusherConnector(options?: PusherConnectorOptions): FluxConnector & ConnectorDebugCarrier;
92
+ declare function createSubscription<TManifest extends GeneratedBroadcastManifest, TChannel extends string>(channelName: TChannel, kind: FluxChannelKind, connector: FluxConnector, registry: SubscriptionRegistry): FluxSubscription<TManifest, TChannel> & {
93
+ readonly __presenceMembers: () => readonly BroadcastJsonObject[];
94
+ readonly __onPresenceChange: (callback: (members: readonly BroadcastJsonObject[]) => void) => () => void;
95
+ };
96
+ declare function createPresenceSubscription<TManifest extends GeneratedBroadcastManifest, TChannel extends string>(name: TChannel, connector: FluxConnector, registry: SubscriptionRegistry): FluxPresenceSubscription<TManifest, TChannel>;
97
+ declare function createFluxClient<TManifest extends GeneratedBroadcastManifest = GeneratedBroadcastManifest>(options?: FluxClientOptions<TManifest>): FluxClient<TManifest> & ConnectorDebugCarrier;
98
+ declare function configureFluxClient(options: FluxClientOptions | FluxClient): FluxClient;
99
+ declare function getFluxClient(): FluxClient;
100
+ declare function resetFluxClient(): void;
101
+ declare const flux: FluxClient<GeneratedBroadcastManifest>;
102
+ declare const fluxInternals: {
103
+ createUnavailableConnector: typeof createUnavailableConnector;
104
+ createPusherConnector: typeof createPusherConnector;
105
+ createPresenceSubscription: typeof createPresenceSubscription;
106
+ createSubscription: typeof createSubscription;
107
+ };
108
+
109
+ export { type FluxChannelKind, type FluxClient, type FluxClientOptions, type FluxConnectionControls, type FluxConnectionStatus, type FluxConnector, type FluxConnectorChannel, type FluxListenerControls, type FluxPresenceState, type FluxPresenceSubscription, type FluxSubscription, configureFluxClient, createFluxClient, flux as default, flux, fluxInternals, getFluxClient, resetFluxClient };
package/dist/index.mjs ADDED
@@ -0,0 +1,383 @@
1
+ // src/index.ts
2
+ function normalizeRequiredString(value, label) {
3
+ const normalized = value.trim();
4
+ if (!normalized) {
5
+ throw new Error(`[@holo-js/flux] ${label} must be a non-empty string.`);
6
+ }
7
+ return normalized;
8
+ }
9
+ function toReadonlyArray(value) {
10
+ return Array.isArray(value) ? [...value] : [value];
11
+ }
12
+ function addCallback(map, event, callback) {
13
+ const listeners = map.get(event) ?? /* @__PURE__ */ new Set();
14
+ listeners.add(callback);
15
+ map.set(event, listeners);
16
+ return () => {
17
+ listeners.delete(callback);
18
+ if (listeners.size === 0) {
19
+ map.delete(event);
20
+ }
21
+ };
22
+ }
23
+ function notifyStatusListeners(listeners, status) {
24
+ for (const listener of listeners) {
25
+ listener(status);
26
+ }
27
+ }
28
+ function createUnavailableConnector() {
29
+ const statusListeners = /* @__PURE__ */ new Set();
30
+ let status = "idle";
31
+ const throwUnavailable = () => {
32
+ throw new Error("[@holo-js/flux] No realtime connector configured. Pass connector or connectorFactory to createFluxClient(...).");
33
+ };
34
+ return Object.freeze({
35
+ async connect() {
36
+ throwUnavailable();
37
+ },
38
+ async disconnect() {
39
+ if (status !== "disconnected") {
40
+ status = "disconnected";
41
+ notifyStatusListeners(statusListeners, status);
42
+ }
43
+ },
44
+ getStatus() {
45
+ return status;
46
+ },
47
+ onStatusChange(callback) {
48
+ statusListeners.add(callback);
49
+ return () => {
50
+ statusListeners.delete(callback);
51
+ };
52
+ },
53
+ subscribe() {
54
+ return throwUnavailable();
55
+ }
56
+ });
57
+ }
58
+ function createPusherConnector(options = {}) {
59
+ const channels = /* @__PURE__ */ new Map();
60
+ const statusListeners = /* @__PURE__ */ new Set();
61
+ let status = "idle";
62
+ void options;
63
+ const ensureChannel = (name, kind) => {
64
+ const key = `${kind}:${name}`;
65
+ const existing = channels.get(key);
66
+ if (existing) {
67
+ return existing;
68
+ }
69
+ const state = {
70
+ name,
71
+ kind,
72
+ eventListeners: /* @__PURE__ */ new Map(),
73
+ whisperListeners: /* @__PURE__ */ new Map(),
74
+ notificationListeners: /* @__PURE__ */ new Set(),
75
+ memberListeners: /* @__PURE__ */ new Set(),
76
+ members: Object.freeze([])
77
+ };
78
+ channels.set(key, state);
79
+ return state;
80
+ };
81
+ const debug = Object.freeze({
82
+ emitEvent(channel, event, payload) {
83
+ for (const state of channels.values()) {
84
+ if (state.name !== channel) {
85
+ continue;
86
+ }
87
+ for (const callback of state.eventListeners.get(event) ?? []) {
88
+ callback(payload);
89
+ }
90
+ }
91
+ },
92
+ emitNotification(channel, payload) {
93
+ for (const state of channels.values()) {
94
+ if (state.name !== channel) {
95
+ continue;
96
+ }
97
+ for (const callback of state.notificationListeners) {
98
+ callback(payload);
99
+ }
100
+ }
101
+ },
102
+ updatePresenceMembers(channel, members) {
103
+ for (const state of channels.values()) {
104
+ if (state.name === channel && state.kind === "presence") {
105
+ state.members = Object.freeze([...members]);
106
+ for (const callback of state.memberListeners) {
107
+ callback(state.members);
108
+ }
109
+ }
110
+ }
111
+ },
112
+ getJoinedChannels() {
113
+ return Object.freeze([...channels.values()].map((state) => `${state.kind}:${state.name}`));
114
+ }
115
+ });
116
+ return Object.freeze({
117
+ __debug: debug,
118
+ async connect() {
119
+ if (status === "connected") {
120
+ return;
121
+ }
122
+ status = "connecting";
123
+ notifyStatusListeners(statusListeners, status);
124
+ status = "connected";
125
+ notifyStatusListeners(statusListeners, status);
126
+ },
127
+ async disconnect() {
128
+ status = "disconnected";
129
+ notifyStatusListeners(statusListeners, status);
130
+ channels.clear();
131
+ },
132
+ getStatus() {
133
+ return status;
134
+ },
135
+ onStatusChange(callback) {
136
+ statusListeners.add(callback);
137
+ return () => {
138
+ statusListeners.delete(callback);
139
+ };
140
+ },
141
+ subscribe(channel, kind) {
142
+ const state = ensureChannel(channel, kind);
143
+ return Object.freeze({
144
+ name: state.name,
145
+ kind: state.kind,
146
+ get members() {
147
+ return state.members;
148
+ },
149
+ onEvent(event, callback) {
150
+ return addCallback(state.eventListeners, event, callback);
151
+ },
152
+ onMembersChange(callback) {
153
+ state.memberListeners.add(callback);
154
+ return () => {
155
+ state.memberListeners.delete(callback);
156
+ };
157
+ },
158
+ onNotification(callback) {
159
+ state.notificationListeners.add(callback);
160
+ return () => {
161
+ state.notificationListeners.delete(callback);
162
+ };
163
+ },
164
+ onWhisper(name, callback) {
165
+ return addCallback(state.whisperListeners, name, callback);
166
+ },
167
+ async sendWhisper(name, payload) {
168
+ for (const callback of state.whisperListeners.get(name) ?? []) {
169
+ callback(payload);
170
+ }
171
+ },
172
+ leave() {
173
+ channels.delete(`${state.kind}:${state.name}`);
174
+ }
175
+ });
176
+ }
177
+ });
178
+ }
179
+ function createSubscription(channelName, kind, connector, registry) {
180
+ const connectorChannel = connector.subscribe(channelName, kind);
181
+ let active = true;
182
+ const detachCallbacks = /* @__PURE__ */ new Set();
183
+ const connectedEvents = /* @__PURE__ */ new Set();
184
+ const eventHandlers = /* @__PURE__ */ new Map();
185
+ const whisperHandlers = /* @__PURE__ */ new Map();
186
+ const registryKey = `${kind}:${channelName.replace(/^private-/, "").replace(/^presence-/, "")}`;
187
+ let left = false;
188
+ const notificationHandlers = /* @__PURE__ */ new Set();
189
+ const registeredSubscriptions = registry.get(registryKey) ?? /* @__PURE__ */ new Set();
190
+ registry.set(registryKey, registeredSubscriptions);
191
+ const runWhenActive = (callback) => {
192
+ return (payload) => {
193
+ if (active) {
194
+ callback(payload);
195
+ }
196
+ };
197
+ };
198
+ const ensureEvent = (event, callback) => {
199
+ const normalizedEvent = normalizeRequiredString(event, "Flux event");
200
+ const listeners = eventHandlers.get(normalizedEvent) ?? /* @__PURE__ */ new Set();
201
+ listeners.add(callback);
202
+ eventHandlers.set(normalizedEvent, listeners);
203
+ if (!connectedEvents.has(normalizedEvent)) {
204
+ connectedEvents.add(normalizedEvent);
205
+ const stop = connectorChannel.onEvent(normalizedEvent, runWhenActive((payload) => {
206
+ for (const listener of eventHandlers.get(normalizedEvent) ?? []) {
207
+ listener(payload);
208
+ }
209
+ }));
210
+ detachCallbacks.add(stop);
211
+ }
212
+ };
213
+ const ensureNotification = (callback) => {
214
+ notificationHandlers.add(callback);
215
+ if (!connectedEvents.has("notification")) {
216
+ connectedEvents.add("notification");
217
+ const stop = connectorChannel.onNotification(runWhenActive((payload) => {
218
+ for (const listener of notificationHandlers) {
219
+ listener(payload);
220
+ }
221
+ }));
222
+ detachCallbacks.add(stop);
223
+ }
224
+ };
225
+ const ensureWhisper = (event, callback) => {
226
+ const normalizedEvent = normalizeRequiredString(event, "Flux whisper event");
227
+ const listeners = whisperHandlers.get(normalizedEvent) ?? /* @__PURE__ */ new Set();
228
+ listeners.add(callback);
229
+ whisperHandlers.set(normalizedEvent, listeners);
230
+ if (!connectedEvents.has(`whisper:${normalizedEvent}`)) {
231
+ connectedEvents.add(`whisper:${normalizedEvent}`);
232
+ const stop = connectorChannel.onWhisper(normalizedEvent, runWhenActive((payload) => {
233
+ for (const listener of whisperHandlers.get(normalizedEvent) ?? []) {
234
+ listener(payload);
235
+ }
236
+ }));
237
+ detachCallbacks.add(stop);
238
+ }
239
+ };
240
+ const stopListening = () => {
241
+ active = false;
242
+ };
243
+ const resumeListening = () => {
244
+ active = true;
245
+ };
246
+ const leaveChannel = () => {
247
+ if (left) {
248
+ return;
249
+ }
250
+ left = true;
251
+ active = false;
252
+ for (const detach of detachCallbacks) {
253
+ detach();
254
+ }
255
+ detachCallbacks.clear();
256
+ registeredSubscriptions.delete(leaveChannel);
257
+ if (registeredSubscriptions.size === 0) {
258
+ registry.delete(registryKey);
259
+ }
260
+ connectorChannel.leave();
261
+ };
262
+ const leaveRelated = () => {
263
+ for (const leave of [...registry.get(registryKey) ?? []]) {
264
+ leave();
265
+ }
266
+ };
267
+ registeredSubscriptions.add(leaveChannel);
268
+ const subscription = {
269
+ name: channelName,
270
+ type: kind,
271
+ leaveChannel,
272
+ leave: leaveRelated,
273
+ stopListening,
274
+ listen(event, callback) {
275
+ if (typeof event === "undefined" || typeof callback === "undefined") {
276
+ resumeListening();
277
+ return this;
278
+ }
279
+ for (const entry of toReadonlyArray(event)) {
280
+ ensureEvent(String(entry), callback);
281
+ }
282
+ return this;
283
+ },
284
+ notification(callback) {
285
+ ensureNotification(callback);
286
+ return this;
287
+ },
288
+ listenForWhisper(name, callback) {
289
+ ensureWhisper(name, callback);
290
+ return this;
291
+ },
292
+ async whisper(name, payload) {
293
+ await connectorChannel.sendWhisper(normalizeRequiredString(name, "Flux whisper event"), payload);
294
+ },
295
+ __presenceMembers() {
296
+ return connectorChannel.members;
297
+ },
298
+ __onPresenceChange(callback) {
299
+ return connectorChannel.onMembersChange(callback);
300
+ }
301
+ };
302
+ return Object.freeze(subscription);
303
+ }
304
+ function createPresenceSubscription(name, connector, registry) {
305
+ const base = createSubscription(name, "presence", connector, registry);
306
+ return Object.freeze({
307
+ ...base,
308
+ get members() {
309
+ return base.__presenceMembers();
310
+ }
311
+ });
312
+ }
313
+ function createFluxClient(options = {}) {
314
+ const connector = options.connector ?? options.connectorFactory?.(options) ?? createUnavailableConnector();
315
+ const subscriptionRegistry = /* @__PURE__ */ new Map();
316
+ const client = {
317
+ options: Object.freeze({ ...options }),
318
+ get status() {
319
+ return connector.getStatus();
320
+ },
321
+ async connect() {
322
+ await connector.connect();
323
+ },
324
+ async disconnect() {
325
+ await connector.disconnect();
326
+ },
327
+ getStatus() {
328
+ return connector.getStatus();
329
+ },
330
+ onStatusChange(callback) {
331
+ return connector.onStatusChange(callback);
332
+ },
333
+ channel(name) {
334
+ return createSubscription(name, "public", connector, subscriptionRegistry);
335
+ },
336
+ private(name) {
337
+ return createSubscription(name, "private", connector, subscriptionRegistry);
338
+ },
339
+ presence(name) {
340
+ return createPresenceSubscription(name, connector, subscriptionRegistry);
341
+ },
342
+ ..."__debug" in connector ? { __debug: connector.__debug } : {}
343
+ };
344
+ return Object.freeze(client);
345
+ }
346
+ var defaultFluxClient = createFluxClient();
347
+ function configureFluxClient(options) {
348
+ defaultFluxClient = "channel" in options ? options : createFluxClient(options);
349
+ return defaultFluxClient;
350
+ }
351
+ function getFluxClient() {
352
+ return defaultFluxClient;
353
+ }
354
+ function resetFluxClient() {
355
+ defaultFluxClient = createFluxClient();
356
+ }
357
+ var flux = new Proxy({}, {
358
+ get(_target, property) {
359
+ return Reflect.get(getFluxClient(), property);
360
+ },
361
+ has(_target, property) {
362
+ return Reflect.has(getFluxClient(), property);
363
+ },
364
+ getPrototypeOf() {
365
+ return Reflect.getPrototypeOf(getFluxClient());
366
+ }
367
+ });
368
+ var fluxInternals = {
369
+ createUnavailableConnector,
370
+ createPusherConnector,
371
+ createPresenceSubscription,
372
+ createSubscription
373
+ };
374
+ var src_default = flux;
375
+ export {
376
+ configureFluxClient,
377
+ createFluxClient,
378
+ src_default as default,
379
+ flux,
380
+ fluxInternals,
381
+ getFluxClient,
382
+ resetFluxClient
383
+ };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@holo-js/flux",
3
+ "version": "0.1.3",
4
+ "description": "Holo-JS Framework - framework-agnostic realtime client skeleton for broadcast and presence",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.mjs",
11
+ "default": "./dist/index.mjs"
12
+ }
13
+ },
14
+ "main": "./dist/index.mjs",
15
+ "types": "./dist/index.d.ts",
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "stub": "tsup",
22
+ "typecheck": "tsc -p tsconfig.json --noEmit",
23
+ "test": "vitest --run"
24
+ },
25
+ "dependencies": {
26
+ "@holo-js/broadcast": "^0.1.3"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^22.10.2",
30
+ "tsup": "^8.3.5",
31
+ "typescript": "^5.7.2",
32
+ "vitest": "^2.1.8"
33
+ }
34
+ }