floppy-disk 3.7.1-beta.1 → 3.7.2
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/esm/react/create-stream.d.mts +283 -2
- package/esm/react.mjs +32 -21
- package/package.json +1 -4
- package/react/create-stream.d.ts +283 -2
- package/react.js +32 -21
|
@@ -33,6 +33,50 @@ type StreamDataState<TData, TError> = {
|
|
|
33
33
|
error: TError;
|
|
34
34
|
errorUpdatedAt: number;
|
|
35
35
|
};
|
|
36
|
+
/**
|
|
37
|
+
* Represents the full state of a stream.
|
|
38
|
+
*
|
|
39
|
+
* @remarks
|
|
40
|
+
* A stream consists of two independent concerns:
|
|
41
|
+
*
|
|
42
|
+
* 1. **Connection state** — lifecycle of the underlying connection
|
|
43
|
+
* 2. **Data state** — lifecycle of emitted data
|
|
44
|
+
*
|
|
45
|
+
* These two are combined into a single state object.
|
|
46
|
+
*
|
|
47
|
+
* ---
|
|
48
|
+
*
|
|
49
|
+
* ## Connection lifecycle
|
|
50
|
+
*
|
|
51
|
+
* - `INITIAL` → no connection has been established
|
|
52
|
+
* - `CONNECTING` → connection is being established
|
|
53
|
+
* - `CONNECTED` → connection is active
|
|
54
|
+
* - `DISCONNECTED` → connection was previously established but is now closed
|
|
55
|
+
*
|
|
56
|
+
* Timestamps:
|
|
57
|
+
* - `connectingAt` → when connection attempt started
|
|
58
|
+
* - `connectedAt` → when connection was established
|
|
59
|
+
* - `disconnectedAt` → when connection was closed
|
|
60
|
+
*
|
|
61
|
+
* ---
|
|
62
|
+
*
|
|
63
|
+
* ## Data lifecycle
|
|
64
|
+
*
|
|
65
|
+
* - `INITIAL` → no data has been received
|
|
66
|
+
* - `SUCCESS` → data has been received successfully
|
|
67
|
+
* - `ERROR` → error occurred before any data
|
|
68
|
+
* - `SUCCESS_BUT_THEN_ERROR` → data exists, but a later error occurred
|
|
69
|
+
*
|
|
70
|
+
* ---
|
|
71
|
+
*
|
|
72
|
+
* ## Notes
|
|
73
|
+
*
|
|
74
|
+
* - Connection state and data state evolve independently.
|
|
75
|
+
* - A stream may be:
|
|
76
|
+
* - connected but have no data yet
|
|
77
|
+
* - disconnected but still retain previous data
|
|
78
|
+
* - Errors do not necessarily reset data.
|
|
79
|
+
*/
|
|
36
80
|
export type StreamState<TData, TError> = ({
|
|
37
81
|
connectionState: "INITIAL";
|
|
38
82
|
connectingAt: undefined;
|
|
@@ -43,8 +87,8 @@ export type StreamState<TData, TError> = ({
|
|
|
43
87
|
}>) | ({
|
|
44
88
|
connectionState: "CONNECTING";
|
|
45
89
|
connectingAt: number;
|
|
46
|
-
connectedAt:
|
|
47
|
-
disconnectedAt:
|
|
90
|
+
connectedAt: undefined;
|
|
91
|
+
disconnectedAt: undefined;
|
|
48
92
|
} & StreamDataState<TData, TError>) | ({
|
|
49
93
|
connectionState: "CONNECTED";
|
|
50
94
|
connectingAt: number;
|
|
@@ -60,25 +104,218 @@ type DisconnectTrigger = "last-unsubscribe" | "document-hidden" | "offline";
|
|
|
60
104
|
type ReconnectTrigger = "first-subscribe" | "document-visible" | "online";
|
|
61
105
|
type AdditionalStoreApi<TConnection> = {
|
|
62
106
|
variableHash: string;
|
|
107
|
+
/**
|
|
108
|
+
* Connection controls for the stream.
|
|
109
|
+
*
|
|
110
|
+
* @remarks
|
|
111
|
+
* Provides imperative control over the underlying connection.
|
|
112
|
+
*/
|
|
63
113
|
connection: {
|
|
114
|
+
/**
|
|
115
|
+
* Returns the current connection instance.
|
|
116
|
+
*
|
|
117
|
+
* @returns The active connection or `undefined` if not connected
|
|
118
|
+
*/
|
|
64
119
|
get: () => Readonly<TConnection> | undefined;
|
|
120
|
+
/**
|
|
121
|
+
* Forces a reconnection.
|
|
122
|
+
*
|
|
123
|
+
* @remarks
|
|
124
|
+
* - Cancels any scheduled disconnect
|
|
125
|
+
* - Starts a new connection if not already connecting
|
|
126
|
+
*/
|
|
65
127
|
reconnect: () => void;
|
|
128
|
+
/**
|
|
129
|
+
* Immediately disconnects the current connection.
|
|
130
|
+
*
|
|
131
|
+
* @remarks
|
|
132
|
+
* - Ignores disconnect delay rules
|
|
133
|
+
* - Updates connection state to `DISCONNECTED`
|
|
134
|
+
*/
|
|
66
135
|
disconnect: () => void;
|
|
67
136
|
};
|
|
137
|
+
/**
|
|
138
|
+
* Data controls for the stream.
|
|
139
|
+
*/
|
|
68
140
|
data: {
|
|
141
|
+
/**
|
|
142
|
+
* Resets the data state back to `INITIAL`.
|
|
143
|
+
*
|
|
144
|
+
* @remarks
|
|
145
|
+
* - Does not affect connection state
|
|
146
|
+
* - Useful for clearing stale or invalid data
|
|
147
|
+
*/
|
|
69
148
|
reset: () => void;
|
|
70
149
|
};
|
|
150
|
+
/**
|
|
151
|
+
* Deletes the stream instance.
|
|
152
|
+
*
|
|
153
|
+
* @returns `true` if deleted, `false` otherwise
|
|
154
|
+
*
|
|
155
|
+
* @remarks
|
|
156
|
+
* - Cannot delete while there are active subscribers
|
|
157
|
+
* - Clears connection, state, and cached instance
|
|
158
|
+
*/
|
|
71
159
|
delete: () => boolean;
|
|
72
160
|
};
|
|
161
|
+
/**
|
|
162
|
+
* Configuration options for a stream.
|
|
163
|
+
*
|
|
164
|
+
* @remarks
|
|
165
|
+
* Controls connection lifecycle, reconnection behavior, and data retention.
|
|
166
|
+
*/
|
|
73
167
|
export type StreamOptions<TConnection, TData, TError = Error> = InitStoreOptions<StreamState<TData, TError>, AdditionalStoreApi<TConnection>> & {
|
|
168
|
+
/**
|
|
169
|
+
* Connection-related behavior.
|
|
170
|
+
*/
|
|
74
171
|
connection?: {
|
|
172
|
+
/**
|
|
173
|
+
* Determines when a connection should be disconnected.
|
|
174
|
+
*
|
|
175
|
+
* @param trigger - The reason for the disconnect attempt
|
|
176
|
+
* @param state - Current stream state
|
|
177
|
+
*
|
|
178
|
+
* @returns
|
|
179
|
+
* - `number` → delay (ms) before disconnecting
|
|
180
|
+
* - `false` → prevent disconnection
|
|
181
|
+
*
|
|
182
|
+
* @default Disconnect after 5 seconds for any triggers
|
|
183
|
+
*
|
|
184
|
+
* @remarks
|
|
185
|
+
* Triggers:
|
|
186
|
+
* - `"last-unsubscribe"` → no active subscribers
|
|
187
|
+
* - `"document-hidden"` → tab becomes hidden
|
|
188
|
+
* - `"offline"` → network goes offline
|
|
189
|
+
*/
|
|
75
190
|
disconnectOn?: (trigger: DisconnectTrigger, state: StreamState<TData, TError>) => false | number;
|
|
191
|
+
/**
|
|
192
|
+
* Determines whether a connection should reconnect.
|
|
193
|
+
*
|
|
194
|
+
* @param trigger - The reason for the reconnect attempt
|
|
195
|
+
* @param state - Current stream state
|
|
196
|
+
*
|
|
197
|
+
* @returns `true` to reconnect, otherwise `false`
|
|
198
|
+
*
|
|
199
|
+
* @default No reconnection if already connected
|
|
200
|
+
*
|
|
201
|
+
* @remarks
|
|
202
|
+
* Triggers:
|
|
203
|
+
* - `"first-subscribe"` → first subscriber appears
|
|
204
|
+
* - `"document-visible"` → tab becomes visible
|
|
205
|
+
* - `"online"` → network reconnects
|
|
206
|
+
*/
|
|
76
207
|
reconnectOn?: (trigger: ReconnectTrigger, state: StreamState<TData, TError>) => boolean;
|
|
77
208
|
};
|
|
209
|
+
/**
|
|
210
|
+
* Data-related behavior.
|
|
211
|
+
*/
|
|
78
212
|
data?: {
|
|
213
|
+
/**
|
|
214
|
+
* Time (in milliseconds) before unused stream data is garbage collected.
|
|
215
|
+
*
|
|
216
|
+
* Starts counting after disconnection.
|
|
217
|
+
*
|
|
218
|
+
* @default 5 minutes
|
|
219
|
+
*/
|
|
79
220
|
gcTime?: number;
|
|
80
221
|
};
|
|
81
222
|
};
|
|
223
|
+
/**
|
|
224
|
+
* Creates a stream factory for managing real-time connections.
|
|
225
|
+
*
|
|
226
|
+
* @param connect - Function to establish a connection
|
|
227
|
+
* @param disconnect - Function to close a connection
|
|
228
|
+
* @param options - Optional configuration for lifecycle and behavior
|
|
229
|
+
*
|
|
230
|
+
* @returns A function to retrieve or create a stream instance by variable
|
|
231
|
+
*
|
|
232
|
+
* @remarks
|
|
233
|
+
* This utility is designed for **long-lived, push-based async sources**, such as:
|
|
234
|
+
* - WebSocket
|
|
235
|
+
* - Server-Sent Events (SSE)
|
|
236
|
+
* - Firebase / realtime databases
|
|
237
|
+
*
|
|
238
|
+
* ---
|
|
239
|
+
*
|
|
240
|
+
* ## Key concepts
|
|
241
|
+
*
|
|
242
|
+
* ### 1. Connection lifecycle (managed automatically)
|
|
243
|
+
*
|
|
244
|
+
* - Connection is established when needed (e.g. first subscriber)
|
|
245
|
+
* - Connection may be disconnected based on triggers:
|
|
246
|
+
* - no subscribers
|
|
247
|
+
* - tab hidden
|
|
248
|
+
* - offline
|
|
249
|
+
* - Reconnection is controlled via `reconnectOn`
|
|
250
|
+
*
|
|
251
|
+
* ---
|
|
252
|
+
*
|
|
253
|
+
* ### 2. Data flow (push-based)
|
|
254
|
+
*
|
|
255
|
+
* The `connect` function receives an `emit` API:
|
|
256
|
+
*
|
|
257
|
+
* - `emit.connected()` → mark connection as established
|
|
258
|
+
* - `emit.data(fn)` → update data using reducer
|
|
259
|
+
* - `emit.error(err)` → report error
|
|
260
|
+
*
|
|
261
|
+
* Data updates are **incremental** and controlled by the stream source.
|
|
262
|
+
*
|
|
263
|
+
* ---
|
|
264
|
+
*
|
|
265
|
+
* ### 3. Store-per-variable
|
|
266
|
+
*
|
|
267
|
+
* - Each unique `variable` creates a separate stream instance
|
|
268
|
+
* - Variables are deterministically hashed for stable identity
|
|
269
|
+
* - Each instance manages its own:
|
|
270
|
+
* - connection
|
|
271
|
+
* - state
|
|
272
|
+
* - subscribers
|
|
273
|
+
*
|
|
274
|
+
* ---
|
|
275
|
+
*
|
|
276
|
+
* ### 4. React integration (Proxy-based)
|
|
277
|
+
*
|
|
278
|
+
* - The returned hook exposes the full state as a Proxy
|
|
279
|
+
* - Components automatically subscribe to accessed properties
|
|
280
|
+
* - No selector or memoization is required
|
|
281
|
+
*
|
|
282
|
+
* ---
|
|
283
|
+
*
|
|
284
|
+
* ## Execution model
|
|
285
|
+
*
|
|
286
|
+
* - Streams are **lazy**:
|
|
287
|
+
* - No connection until there is a subscriber
|
|
288
|
+
* - Streams are **shared**:
|
|
289
|
+
* - Multiple subscribers reuse the same connection
|
|
290
|
+
* - Streams are **stateful**:
|
|
291
|
+
* - Data persists across reconnects (unless reset or GC)
|
|
292
|
+
*
|
|
293
|
+
* ---
|
|
294
|
+
*
|
|
295
|
+
* @example
|
|
296
|
+
* const chatStream = createStream(
|
|
297
|
+
* (roomId, emit) => {
|
|
298
|
+
* const ws = new WebSocket(`/chat/${roomId}`);
|
|
299
|
+
*
|
|
300
|
+
* ws.onopen = () => emit.connected();
|
|
301
|
+
* ws.onmessage = (e) => {
|
|
302
|
+
* const msg = JSON.parse(e.data);
|
|
303
|
+
* emit.data((prev) => [...(prev ?? []), msg]);
|
|
304
|
+
* };
|
|
305
|
+
* ws.onerror = (err) => emit.error(err);
|
|
306
|
+
*
|
|
307
|
+
* return ws;
|
|
308
|
+
* },
|
|
309
|
+
* (ws) => ws.close()
|
|
310
|
+
* );
|
|
311
|
+
*
|
|
312
|
+
* function Chat({ roomId }) {
|
|
313
|
+
* const useChat = chatStream(roomId);
|
|
314
|
+
* const state = useChat();
|
|
315
|
+
*
|
|
316
|
+
* return <div>{state.data?.length}</div>;
|
|
317
|
+
* }
|
|
318
|
+
*/
|
|
82
319
|
export declare const experimental_createStream: <TConnection, TData, TVariable extends StoreKey, TError = Error>(connect: (variable: TVariable, emit: {
|
|
83
320
|
connected: () => void;
|
|
84
321
|
data: (reducer: (data: TData | undefined) => TData) => void;
|
|
@@ -91,14 +328,58 @@ export declare const experimental_createStream: <TConnection, TData, TVariable e
|
|
|
91
328
|
subscribe: (subscriber: import("../vanilla.d.mts").Subscriber<StreamState<TData, TError>>) => () => void;
|
|
92
329
|
getSubscriberCount: () => number;
|
|
93
330
|
variableHash: string;
|
|
331
|
+
/**
|
|
332
|
+
* Connection controls for the stream.
|
|
333
|
+
*
|
|
334
|
+
* @remarks
|
|
335
|
+
* Provides imperative control over the underlying connection.
|
|
336
|
+
*/
|
|
94
337
|
connection: {
|
|
338
|
+
/**
|
|
339
|
+
* Returns the current connection instance.
|
|
340
|
+
*
|
|
341
|
+
* @returns The active connection or `undefined` if not connected
|
|
342
|
+
*/
|
|
95
343
|
get: () => Readonly<TConnection> | undefined;
|
|
344
|
+
/**
|
|
345
|
+
* Forces a reconnection.
|
|
346
|
+
*
|
|
347
|
+
* @remarks
|
|
348
|
+
* - Cancels any scheduled disconnect
|
|
349
|
+
* - Starts a new connection if not already connecting
|
|
350
|
+
*/
|
|
96
351
|
reconnect: () => void;
|
|
352
|
+
/**
|
|
353
|
+
* Immediately disconnects the current connection.
|
|
354
|
+
*
|
|
355
|
+
* @remarks
|
|
356
|
+
* - Ignores disconnect delay rules
|
|
357
|
+
* - Updates connection state to `DISCONNECTED`
|
|
358
|
+
*/
|
|
97
359
|
disconnect: () => void;
|
|
98
360
|
};
|
|
361
|
+
/**
|
|
362
|
+
* Data controls for the stream.
|
|
363
|
+
*/
|
|
99
364
|
data: {
|
|
365
|
+
/**
|
|
366
|
+
* Resets the data state back to `INITIAL`.
|
|
367
|
+
*
|
|
368
|
+
* @remarks
|
|
369
|
+
* - Does not affect connection state
|
|
370
|
+
* - Useful for clearing stale or invalid data
|
|
371
|
+
*/
|
|
100
372
|
reset: () => void;
|
|
101
373
|
};
|
|
374
|
+
/**
|
|
375
|
+
* Deletes the stream instance.
|
|
376
|
+
*
|
|
377
|
+
* @returns `true` if deleted, `false` otherwise
|
|
378
|
+
*
|
|
379
|
+
* @remarks
|
|
380
|
+
* - Cannot delete while there are active subscribers
|
|
381
|
+
* - Clears connection, state, and cached instance
|
|
382
|
+
*/
|
|
102
383
|
delete: () => boolean;
|
|
103
384
|
};
|
|
104
385
|
export {};
|
package/esm/react.mjs
CHANGED
|
@@ -785,6 +785,19 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
|
|
|
785
785
|
const connections = /* @__PURE__ */ new WeakMap();
|
|
786
786
|
const disconnectFns = /* @__PURE__ */ new WeakMap();
|
|
787
787
|
const disconnectTimeoutIds = /* @__PURE__ */ new WeakMap();
|
|
788
|
+
const clearAllTimeouts = (store) => {
|
|
789
|
+
const gcTimeoutId = gcTimeoutIds.get(store);
|
|
790
|
+
if (gcTimeoutId) {
|
|
791
|
+
clearTimeout(gcTimeoutId);
|
|
792
|
+
gcTimeoutIds.delete(store);
|
|
793
|
+
}
|
|
794
|
+
const disconnectTimeoutIds_ = disconnectTimeoutIds.get(store);
|
|
795
|
+
if (disconnectTimeoutIds_) {
|
|
796
|
+
clearTimeout(disconnectTimeoutIds_["last-unsubscribe"]);
|
|
797
|
+
clearTimeout(disconnectTimeoutIds_["document-hidden"]);
|
|
798
|
+
clearTimeout(disconnectTimeoutIds_.offline);
|
|
799
|
+
}
|
|
800
|
+
};
|
|
788
801
|
const gcTimeoutIds = /* @__PURE__ */ new WeakMap();
|
|
789
802
|
const configureStoreEvents = () => ({
|
|
790
803
|
...options,
|
|
@@ -827,12 +840,7 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
|
|
|
827
840
|
if (store.getSubscriberCount() && showLog) {
|
|
828
841
|
console.log("Stream disconnected while there is subscriber");
|
|
829
842
|
}
|
|
830
|
-
|
|
831
|
-
if (disconnectTimeoutIds_) {
|
|
832
|
-
clearTimeout(disconnectTimeoutIds_["last-unsubscribe"]);
|
|
833
|
-
clearTimeout(disconnectTimeoutIds_["document-hidden"]);
|
|
834
|
-
clearTimeout(disconnectTimeoutIds_.offline);
|
|
835
|
-
}
|
|
843
|
+
clearAllTimeouts(store);
|
|
836
844
|
(_a = disconnectFns.get(store)) == null ? void 0 : _a();
|
|
837
845
|
if (store.getState().connectionState !== "INITIAL") {
|
|
838
846
|
store.setState({
|
|
@@ -845,8 +853,7 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
|
|
|
845
853
|
gcTimeoutIds.set(
|
|
846
854
|
store,
|
|
847
855
|
setTimeout(() => {
|
|
848
|
-
if (store.getSubscriberCount()) store.
|
|
849
|
-
else store.delete();
|
|
856
|
+
if (store.getSubscriberCount() === 0) store.delete();
|
|
850
857
|
}, gcTime)
|
|
851
858
|
);
|
|
852
859
|
};
|
|
@@ -866,13 +873,19 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
|
|
|
866
873
|
store.connection = {};
|
|
867
874
|
store.connection.get = () => connections.get(store);
|
|
868
875
|
store.connection.reconnect = () => {
|
|
869
|
-
|
|
876
|
+
clearAllTimeouts(store);
|
|
870
877
|
const { connectionState } = store.getState();
|
|
871
878
|
if (connectionState === "CONNECTING") return;
|
|
872
|
-
|
|
879
|
+
const prevDisconnect = disconnectFns.get(store);
|
|
880
|
+
if (prevDisconnect) {
|
|
881
|
+
prevDisconnect();
|
|
882
|
+
disconnectFns.delete(store);
|
|
883
|
+
}
|
|
873
884
|
store.setState({
|
|
874
885
|
connectionState: "CONNECTING",
|
|
875
|
-
connectingAt: Date.now()
|
|
886
|
+
connectingAt: Date.now(),
|
|
887
|
+
connectedAt: void 0,
|
|
888
|
+
disconnectedAt: void 0
|
|
876
889
|
});
|
|
877
890
|
const connection = connect(variable, {
|
|
878
891
|
connected: () => {
|
|
@@ -884,10 +897,10 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
|
|
|
884
897
|
},
|
|
885
898
|
data: (reducer) => {
|
|
886
899
|
store.setState((prev) => {
|
|
887
|
-
var
|
|
900
|
+
var _a;
|
|
888
901
|
return {
|
|
889
902
|
connectionState: "CONNECTED",
|
|
890
|
-
connectedAt: (
|
|
903
|
+
connectedAt: (_a = prev.connectedAt) != null ? _a : Date.now(),
|
|
891
904
|
state: "SUCCESS",
|
|
892
905
|
isSuccess: true,
|
|
893
906
|
isError: false,
|
|
@@ -930,6 +943,7 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
|
|
|
930
943
|
);
|
|
931
944
|
return false;
|
|
932
945
|
}
|
|
946
|
+
clearAllTimeouts(store);
|
|
933
947
|
store.setState(initialState);
|
|
934
948
|
return stores.delete(variableHash);
|
|
935
949
|
};
|
|
@@ -946,16 +960,13 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
|
|
|
946
960
|
});
|
|
947
961
|
};
|
|
948
962
|
const triggerReconnect = (store, trigger) => {
|
|
949
|
-
|
|
950
|
-
const disconnectTimeoutIds_ = disconnectTimeoutIds.get(store);
|
|
951
|
-
if (disconnectTimeoutIds_) {
|
|
952
|
-
clearTimeout(disconnectTimeoutIds_["last-unsubscribe"]);
|
|
953
|
-
clearTimeout(disconnectTimeoutIds_["document-hidden"]);
|
|
954
|
-
clearTimeout(disconnectTimeoutIds_.offline);
|
|
955
|
-
}
|
|
963
|
+
clearAllTimeouts(store);
|
|
956
964
|
const { connectionState } = store.getState();
|
|
957
965
|
if (connectionState === "INITIAL" || connectionState === "DISCONNECTED") {
|
|
958
|
-
|
|
966
|
+
queueMicrotask(() => {
|
|
967
|
+
store.connection.reconnect();
|
|
968
|
+
});
|
|
969
|
+
return;
|
|
959
970
|
}
|
|
960
971
|
const shouldReconnect = reconnectOn(trigger, store.getState());
|
|
961
972
|
if (shouldReconnect) store.connection.reconnect();
|
package/package.json
CHANGED
|
@@ -2,10 +2,7 @@
|
|
|
2
2
|
"name": "floppy-disk",
|
|
3
3
|
"description": "Lightweight unified state management for sync and async data.",
|
|
4
4
|
"private": false,
|
|
5
|
-
"version": "3.7.
|
|
6
|
-
"publishConfig": {
|
|
7
|
-
"tag": "beta"
|
|
8
|
-
},
|
|
5
|
+
"version": "3.7.2",
|
|
9
6
|
"keywords": [
|
|
10
7
|
"utilities",
|
|
11
8
|
"store",
|
package/react/create-stream.d.ts
CHANGED
|
@@ -33,6 +33,50 @@ type StreamDataState<TData, TError> = {
|
|
|
33
33
|
error: TError;
|
|
34
34
|
errorUpdatedAt: number;
|
|
35
35
|
};
|
|
36
|
+
/**
|
|
37
|
+
* Represents the full state of a stream.
|
|
38
|
+
*
|
|
39
|
+
* @remarks
|
|
40
|
+
* A stream consists of two independent concerns:
|
|
41
|
+
*
|
|
42
|
+
* 1. **Connection state** — lifecycle of the underlying connection
|
|
43
|
+
* 2. **Data state** — lifecycle of emitted data
|
|
44
|
+
*
|
|
45
|
+
* These two are combined into a single state object.
|
|
46
|
+
*
|
|
47
|
+
* ---
|
|
48
|
+
*
|
|
49
|
+
* ## Connection lifecycle
|
|
50
|
+
*
|
|
51
|
+
* - `INITIAL` → no connection has been established
|
|
52
|
+
* - `CONNECTING` → connection is being established
|
|
53
|
+
* - `CONNECTED` → connection is active
|
|
54
|
+
* - `DISCONNECTED` → connection was previously established but is now closed
|
|
55
|
+
*
|
|
56
|
+
* Timestamps:
|
|
57
|
+
* - `connectingAt` → when connection attempt started
|
|
58
|
+
* - `connectedAt` → when connection was established
|
|
59
|
+
* - `disconnectedAt` → when connection was closed
|
|
60
|
+
*
|
|
61
|
+
* ---
|
|
62
|
+
*
|
|
63
|
+
* ## Data lifecycle
|
|
64
|
+
*
|
|
65
|
+
* - `INITIAL` → no data has been received
|
|
66
|
+
* - `SUCCESS` → data has been received successfully
|
|
67
|
+
* - `ERROR` → error occurred before any data
|
|
68
|
+
* - `SUCCESS_BUT_THEN_ERROR` → data exists, but a later error occurred
|
|
69
|
+
*
|
|
70
|
+
* ---
|
|
71
|
+
*
|
|
72
|
+
* ## Notes
|
|
73
|
+
*
|
|
74
|
+
* - Connection state and data state evolve independently.
|
|
75
|
+
* - A stream may be:
|
|
76
|
+
* - connected but have no data yet
|
|
77
|
+
* - disconnected but still retain previous data
|
|
78
|
+
* - Errors do not necessarily reset data.
|
|
79
|
+
*/
|
|
36
80
|
export type StreamState<TData, TError> = ({
|
|
37
81
|
connectionState: "INITIAL";
|
|
38
82
|
connectingAt: undefined;
|
|
@@ -43,8 +87,8 @@ export type StreamState<TData, TError> = ({
|
|
|
43
87
|
}>) | ({
|
|
44
88
|
connectionState: "CONNECTING";
|
|
45
89
|
connectingAt: number;
|
|
46
|
-
connectedAt:
|
|
47
|
-
disconnectedAt:
|
|
90
|
+
connectedAt: undefined;
|
|
91
|
+
disconnectedAt: undefined;
|
|
48
92
|
} & StreamDataState<TData, TError>) | ({
|
|
49
93
|
connectionState: "CONNECTED";
|
|
50
94
|
connectingAt: number;
|
|
@@ -60,25 +104,218 @@ type DisconnectTrigger = "last-unsubscribe" | "document-hidden" | "offline";
|
|
|
60
104
|
type ReconnectTrigger = "first-subscribe" | "document-visible" | "online";
|
|
61
105
|
type AdditionalStoreApi<TConnection> = {
|
|
62
106
|
variableHash: string;
|
|
107
|
+
/**
|
|
108
|
+
* Connection controls for the stream.
|
|
109
|
+
*
|
|
110
|
+
* @remarks
|
|
111
|
+
* Provides imperative control over the underlying connection.
|
|
112
|
+
*/
|
|
63
113
|
connection: {
|
|
114
|
+
/**
|
|
115
|
+
* Returns the current connection instance.
|
|
116
|
+
*
|
|
117
|
+
* @returns The active connection or `undefined` if not connected
|
|
118
|
+
*/
|
|
64
119
|
get: () => Readonly<TConnection> | undefined;
|
|
120
|
+
/**
|
|
121
|
+
* Forces a reconnection.
|
|
122
|
+
*
|
|
123
|
+
* @remarks
|
|
124
|
+
* - Cancels any scheduled disconnect
|
|
125
|
+
* - Starts a new connection if not already connecting
|
|
126
|
+
*/
|
|
65
127
|
reconnect: () => void;
|
|
128
|
+
/**
|
|
129
|
+
* Immediately disconnects the current connection.
|
|
130
|
+
*
|
|
131
|
+
* @remarks
|
|
132
|
+
* - Ignores disconnect delay rules
|
|
133
|
+
* - Updates connection state to `DISCONNECTED`
|
|
134
|
+
*/
|
|
66
135
|
disconnect: () => void;
|
|
67
136
|
};
|
|
137
|
+
/**
|
|
138
|
+
* Data controls for the stream.
|
|
139
|
+
*/
|
|
68
140
|
data: {
|
|
141
|
+
/**
|
|
142
|
+
* Resets the data state back to `INITIAL`.
|
|
143
|
+
*
|
|
144
|
+
* @remarks
|
|
145
|
+
* - Does not affect connection state
|
|
146
|
+
* - Useful for clearing stale or invalid data
|
|
147
|
+
*/
|
|
69
148
|
reset: () => void;
|
|
70
149
|
};
|
|
150
|
+
/**
|
|
151
|
+
* Deletes the stream instance.
|
|
152
|
+
*
|
|
153
|
+
* @returns `true` if deleted, `false` otherwise
|
|
154
|
+
*
|
|
155
|
+
* @remarks
|
|
156
|
+
* - Cannot delete while there are active subscribers
|
|
157
|
+
* - Clears connection, state, and cached instance
|
|
158
|
+
*/
|
|
71
159
|
delete: () => boolean;
|
|
72
160
|
};
|
|
161
|
+
/**
|
|
162
|
+
* Configuration options for a stream.
|
|
163
|
+
*
|
|
164
|
+
* @remarks
|
|
165
|
+
* Controls connection lifecycle, reconnection behavior, and data retention.
|
|
166
|
+
*/
|
|
73
167
|
export type StreamOptions<TConnection, TData, TError = Error> = InitStoreOptions<StreamState<TData, TError>, AdditionalStoreApi<TConnection>> & {
|
|
168
|
+
/**
|
|
169
|
+
* Connection-related behavior.
|
|
170
|
+
*/
|
|
74
171
|
connection?: {
|
|
172
|
+
/**
|
|
173
|
+
* Determines when a connection should be disconnected.
|
|
174
|
+
*
|
|
175
|
+
* @param trigger - The reason for the disconnect attempt
|
|
176
|
+
* @param state - Current stream state
|
|
177
|
+
*
|
|
178
|
+
* @returns
|
|
179
|
+
* - `number` → delay (ms) before disconnecting
|
|
180
|
+
* - `false` → prevent disconnection
|
|
181
|
+
*
|
|
182
|
+
* @default Disconnect after 5 seconds for any triggers
|
|
183
|
+
*
|
|
184
|
+
* @remarks
|
|
185
|
+
* Triggers:
|
|
186
|
+
* - `"last-unsubscribe"` → no active subscribers
|
|
187
|
+
* - `"document-hidden"` → tab becomes hidden
|
|
188
|
+
* - `"offline"` → network goes offline
|
|
189
|
+
*/
|
|
75
190
|
disconnectOn?: (trigger: DisconnectTrigger, state: StreamState<TData, TError>) => false | number;
|
|
191
|
+
/**
|
|
192
|
+
* Determines whether a connection should reconnect.
|
|
193
|
+
*
|
|
194
|
+
* @param trigger - The reason for the reconnect attempt
|
|
195
|
+
* @param state - Current stream state
|
|
196
|
+
*
|
|
197
|
+
* @returns `true` to reconnect, otherwise `false`
|
|
198
|
+
*
|
|
199
|
+
* @default No reconnection if already connected
|
|
200
|
+
*
|
|
201
|
+
* @remarks
|
|
202
|
+
* Triggers:
|
|
203
|
+
* - `"first-subscribe"` → first subscriber appears
|
|
204
|
+
* - `"document-visible"` → tab becomes visible
|
|
205
|
+
* - `"online"` → network reconnects
|
|
206
|
+
*/
|
|
76
207
|
reconnectOn?: (trigger: ReconnectTrigger, state: StreamState<TData, TError>) => boolean;
|
|
77
208
|
};
|
|
209
|
+
/**
|
|
210
|
+
* Data-related behavior.
|
|
211
|
+
*/
|
|
78
212
|
data?: {
|
|
213
|
+
/**
|
|
214
|
+
* Time (in milliseconds) before unused stream data is garbage collected.
|
|
215
|
+
*
|
|
216
|
+
* Starts counting after disconnection.
|
|
217
|
+
*
|
|
218
|
+
* @default 5 minutes
|
|
219
|
+
*/
|
|
79
220
|
gcTime?: number;
|
|
80
221
|
};
|
|
81
222
|
};
|
|
223
|
+
/**
|
|
224
|
+
* Creates a stream factory for managing real-time connections.
|
|
225
|
+
*
|
|
226
|
+
* @param connect - Function to establish a connection
|
|
227
|
+
* @param disconnect - Function to close a connection
|
|
228
|
+
* @param options - Optional configuration for lifecycle and behavior
|
|
229
|
+
*
|
|
230
|
+
* @returns A function to retrieve or create a stream instance by variable
|
|
231
|
+
*
|
|
232
|
+
* @remarks
|
|
233
|
+
* This utility is designed for **long-lived, push-based async sources**, such as:
|
|
234
|
+
* - WebSocket
|
|
235
|
+
* - Server-Sent Events (SSE)
|
|
236
|
+
* - Firebase / realtime databases
|
|
237
|
+
*
|
|
238
|
+
* ---
|
|
239
|
+
*
|
|
240
|
+
* ## Key concepts
|
|
241
|
+
*
|
|
242
|
+
* ### 1. Connection lifecycle (managed automatically)
|
|
243
|
+
*
|
|
244
|
+
* - Connection is established when needed (e.g. first subscriber)
|
|
245
|
+
* - Connection may be disconnected based on triggers:
|
|
246
|
+
* - no subscribers
|
|
247
|
+
* - tab hidden
|
|
248
|
+
* - offline
|
|
249
|
+
* - Reconnection is controlled via `reconnectOn`
|
|
250
|
+
*
|
|
251
|
+
* ---
|
|
252
|
+
*
|
|
253
|
+
* ### 2. Data flow (push-based)
|
|
254
|
+
*
|
|
255
|
+
* The `connect` function receives an `emit` API:
|
|
256
|
+
*
|
|
257
|
+
* - `emit.connected()` → mark connection as established
|
|
258
|
+
* - `emit.data(fn)` → update data using reducer
|
|
259
|
+
* - `emit.error(err)` → report error
|
|
260
|
+
*
|
|
261
|
+
* Data updates are **incremental** and controlled by the stream source.
|
|
262
|
+
*
|
|
263
|
+
* ---
|
|
264
|
+
*
|
|
265
|
+
* ### 3. Store-per-variable
|
|
266
|
+
*
|
|
267
|
+
* - Each unique `variable` creates a separate stream instance
|
|
268
|
+
* - Variables are deterministically hashed for stable identity
|
|
269
|
+
* - Each instance manages its own:
|
|
270
|
+
* - connection
|
|
271
|
+
* - state
|
|
272
|
+
* - subscribers
|
|
273
|
+
*
|
|
274
|
+
* ---
|
|
275
|
+
*
|
|
276
|
+
* ### 4. React integration (Proxy-based)
|
|
277
|
+
*
|
|
278
|
+
* - The returned hook exposes the full state as a Proxy
|
|
279
|
+
* - Components automatically subscribe to accessed properties
|
|
280
|
+
* - No selector or memoization is required
|
|
281
|
+
*
|
|
282
|
+
* ---
|
|
283
|
+
*
|
|
284
|
+
* ## Execution model
|
|
285
|
+
*
|
|
286
|
+
* - Streams are **lazy**:
|
|
287
|
+
* - No connection until there is a subscriber
|
|
288
|
+
* - Streams are **shared**:
|
|
289
|
+
* - Multiple subscribers reuse the same connection
|
|
290
|
+
* - Streams are **stateful**:
|
|
291
|
+
* - Data persists across reconnects (unless reset or GC)
|
|
292
|
+
*
|
|
293
|
+
* ---
|
|
294
|
+
*
|
|
295
|
+
* @example
|
|
296
|
+
* const chatStream = createStream(
|
|
297
|
+
* (roomId, emit) => {
|
|
298
|
+
* const ws = new WebSocket(`/chat/${roomId}`);
|
|
299
|
+
*
|
|
300
|
+
* ws.onopen = () => emit.connected();
|
|
301
|
+
* ws.onmessage = (e) => {
|
|
302
|
+
* const msg = JSON.parse(e.data);
|
|
303
|
+
* emit.data((prev) => [...(prev ?? []), msg]);
|
|
304
|
+
* };
|
|
305
|
+
* ws.onerror = (err) => emit.error(err);
|
|
306
|
+
*
|
|
307
|
+
* return ws;
|
|
308
|
+
* },
|
|
309
|
+
* (ws) => ws.close()
|
|
310
|
+
* );
|
|
311
|
+
*
|
|
312
|
+
* function Chat({ roomId }) {
|
|
313
|
+
* const useChat = chatStream(roomId);
|
|
314
|
+
* const state = useChat();
|
|
315
|
+
*
|
|
316
|
+
* return <div>{state.data?.length}</div>;
|
|
317
|
+
* }
|
|
318
|
+
*/
|
|
82
319
|
export declare const experimental_createStream: <TConnection, TData, TVariable extends StoreKey, TError = Error>(connect: (variable: TVariable, emit: {
|
|
83
320
|
connected: () => void;
|
|
84
321
|
data: (reducer: (data: TData | undefined) => TData) => void;
|
|
@@ -91,14 +328,58 @@ export declare const experimental_createStream: <TConnection, TData, TVariable e
|
|
|
91
328
|
subscribe: (subscriber: import("../vanilla.ts").Subscriber<StreamState<TData, TError>>) => () => void;
|
|
92
329
|
getSubscriberCount: () => number;
|
|
93
330
|
variableHash: string;
|
|
331
|
+
/**
|
|
332
|
+
* Connection controls for the stream.
|
|
333
|
+
*
|
|
334
|
+
* @remarks
|
|
335
|
+
* Provides imperative control over the underlying connection.
|
|
336
|
+
*/
|
|
94
337
|
connection: {
|
|
338
|
+
/**
|
|
339
|
+
* Returns the current connection instance.
|
|
340
|
+
*
|
|
341
|
+
* @returns The active connection or `undefined` if not connected
|
|
342
|
+
*/
|
|
95
343
|
get: () => Readonly<TConnection> | undefined;
|
|
344
|
+
/**
|
|
345
|
+
* Forces a reconnection.
|
|
346
|
+
*
|
|
347
|
+
* @remarks
|
|
348
|
+
* - Cancels any scheduled disconnect
|
|
349
|
+
* - Starts a new connection if not already connecting
|
|
350
|
+
*/
|
|
96
351
|
reconnect: () => void;
|
|
352
|
+
/**
|
|
353
|
+
* Immediately disconnects the current connection.
|
|
354
|
+
*
|
|
355
|
+
* @remarks
|
|
356
|
+
* - Ignores disconnect delay rules
|
|
357
|
+
* - Updates connection state to `DISCONNECTED`
|
|
358
|
+
*/
|
|
97
359
|
disconnect: () => void;
|
|
98
360
|
};
|
|
361
|
+
/**
|
|
362
|
+
* Data controls for the stream.
|
|
363
|
+
*/
|
|
99
364
|
data: {
|
|
365
|
+
/**
|
|
366
|
+
* Resets the data state back to `INITIAL`.
|
|
367
|
+
*
|
|
368
|
+
* @remarks
|
|
369
|
+
* - Does not affect connection state
|
|
370
|
+
* - Useful for clearing stale or invalid data
|
|
371
|
+
*/
|
|
100
372
|
reset: () => void;
|
|
101
373
|
};
|
|
374
|
+
/**
|
|
375
|
+
* Deletes the stream instance.
|
|
376
|
+
*
|
|
377
|
+
* @returns `true` if deleted, `false` otherwise
|
|
378
|
+
*
|
|
379
|
+
* @remarks
|
|
380
|
+
* - Cannot delete while there are active subscribers
|
|
381
|
+
* - Clears connection, state, and cached instance
|
|
382
|
+
*/
|
|
102
383
|
delete: () => boolean;
|
|
103
384
|
};
|
|
104
385
|
export {};
|
package/react.js
CHANGED
|
@@ -787,6 +787,19 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
|
|
|
787
787
|
const connections = /* @__PURE__ */ new WeakMap();
|
|
788
788
|
const disconnectFns = /* @__PURE__ */ new WeakMap();
|
|
789
789
|
const disconnectTimeoutIds = /* @__PURE__ */ new WeakMap();
|
|
790
|
+
const clearAllTimeouts = (store) => {
|
|
791
|
+
const gcTimeoutId = gcTimeoutIds.get(store);
|
|
792
|
+
if (gcTimeoutId) {
|
|
793
|
+
clearTimeout(gcTimeoutId);
|
|
794
|
+
gcTimeoutIds.delete(store);
|
|
795
|
+
}
|
|
796
|
+
const disconnectTimeoutIds_ = disconnectTimeoutIds.get(store);
|
|
797
|
+
if (disconnectTimeoutIds_) {
|
|
798
|
+
clearTimeout(disconnectTimeoutIds_["last-unsubscribe"]);
|
|
799
|
+
clearTimeout(disconnectTimeoutIds_["document-hidden"]);
|
|
800
|
+
clearTimeout(disconnectTimeoutIds_.offline);
|
|
801
|
+
}
|
|
802
|
+
};
|
|
790
803
|
const gcTimeoutIds = /* @__PURE__ */ new WeakMap();
|
|
791
804
|
const configureStoreEvents = () => ({
|
|
792
805
|
...options,
|
|
@@ -829,12 +842,7 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
|
|
|
829
842
|
if (store.getSubscriberCount() && showLog) {
|
|
830
843
|
console.log("Stream disconnected while there is subscriber");
|
|
831
844
|
}
|
|
832
|
-
|
|
833
|
-
if (disconnectTimeoutIds_) {
|
|
834
|
-
clearTimeout(disconnectTimeoutIds_["last-unsubscribe"]);
|
|
835
|
-
clearTimeout(disconnectTimeoutIds_["document-hidden"]);
|
|
836
|
-
clearTimeout(disconnectTimeoutIds_.offline);
|
|
837
|
-
}
|
|
845
|
+
clearAllTimeouts(store);
|
|
838
846
|
(_a = disconnectFns.get(store)) == null ? void 0 : _a();
|
|
839
847
|
if (store.getState().connectionState !== "INITIAL") {
|
|
840
848
|
store.setState({
|
|
@@ -847,8 +855,7 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
|
|
|
847
855
|
gcTimeoutIds.set(
|
|
848
856
|
store,
|
|
849
857
|
setTimeout(() => {
|
|
850
|
-
if (store.getSubscriberCount()) store.
|
|
851
|
-
else store.delete();
|
|
858
|
+
if (store.getSubscriberCount() === 0) store.delete();
|
|
852
859
|
}, gcTime)
|
|
853
860
|
);
|
|
854
861
|
};
|
|
@@ -868,13 +875,19 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
|
|
|
868
875
|
store.connection = {};
|
|
869
876
|
store.connection.get = () => connections.get(store);
|
|
870
877
|
store.connection.reconnect = () => {
|
|
871
|
-
|
|
878
|
+
clearAllTimeouts(store);
|
|
872
879
|
const { connectionState } = store.getState();
|
|
873
880
|
if (connectionState === "CONNECTING") return;
|
|
874
|
-
|
|
881
|
+
const prevDisconnect = disconnectFns.get(store);
|
|
882
|
+
if (prevDisconnect) {
|
|
883
|
+
prevDisconnect();
|
|
884
|
+
disconnectFns.delete(store);
|
|
885
|
+
}
|
|
875
886
|
store.setState({
|
|
876
887
|
connectionState: "CONNECTING",
|
|
877
|
-
connectingAt: Date.now()
|
|
888
|
+
connectingAt: Date.now(),
|
|
889
|
+
connectedAt: void 0,
|
|
890
|
+
disconnectedAt: void 0
|
|
878
891
|
});
|
|
879
892
|
const connection = connect(variable, {
|
|
880
893
|
connected: () => {
|
|
@@ -886,10 +899,10 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
|
|
|
886
899
|
},
|
|
887
900
|
data: (reducer) => {
|
|
888
901
|
store.setState((prev) => {
|
|
889
|
-
var
|
|
902
|
+
var _a;
|
|
890
903
|
return {
|
|
891
904
|
connectionState: "CONNECTED",
|
|
892
|
-
connectedAt: (
|
|
905
|
+
connectedAt: (_a = prev.connectedAt) != null ? _a : Date.now(),
|
|
893
906
|
state: "SUCCESS",
|
|
894
907
|
isSuccess: true,
|
|
895
908
|
isError: false,
|
|
@@ -932,6 +945,7 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
|
|
|
932
945
|
);
|
|
933
946
|
return false;
|
|
934
947
|
}
|
|
948
|
+
clearAllTimeouts(store);
|
|
935
949
|
store.setState(initialState);
|
|
936
950
|
return stores.delete(variableHash);
|
|
937
951
|
};
|
|
@@ -948,16 +962,13 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
|
|
|
948
962
|
});
|
|
949
963
|
};
|
|
950
964
|
const triggerReconnect = (store, trigger) => {
|
|
951
|
-
|
|
952
|
-
const disconnectTimeoutIds_ = disconnectTimeoutIds.get(store);
|
|
953
|
-
if (disconnectTimeoutIds_) {
|
|
954
|
-
clearTimeout(disconnectTimeoutIds_["last-unsubscribe"]);
|
|
955
|
-
clearTimeout(disconnectTimeoutIds_["document-hidden"]);
|
|
956
|
-
clearTimeout(disconnectTimeoutIds_.offline);
|
|
957
|
-
}
|
|
965
|
+
clearAllTimeouts(store);
|
|
958
966
|
const { connectionState } = store.getState();
|
|
959
967
|
if (connectionState === "INITIAL" || connectionState === "DISCONNECTED") {
|
|
960
|
-
|
|
968
|
+
queueMicrotask(() => {
|
|
969
|
+
store.connection.reconnect();
|
|
970
|
+
});
|
|
971
|
+
return;
|
|
961
972
|
}
|
|
962
973
|
const shouldReconnect = reconnectOn(trigger, store.getState());
|
|
963
974
|
if (shouldReconnect) store.connection.reconnect();
|