react-ws-context 0.1.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.mjs ADDED
@@ -0,0 +1,403 @@
1
+ "use client";
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/ws-context/emitter.ts
5
+ function createEmitter() {
6
+ return {
7
+ events: {},
8
+ emit(event, ...args) {
9
+ const callbacks = this.events[event] || [];
10
+ for (let i = 0, len = callbacks.length; i < len; i++) callbacks[i](...args);
11
+ },
12
+ on(event, cb) {
13
+ (this.events[event] ||= []).push(cb);
14
+ return () => {
15
+ this.events[event] = this.events[event]?.filter((fn) => fn !== cb);
16
+ };
17
+ }
18
+ };
19
+ }
20
+ function useEmitter() {
21
+ const [emitter] = useState(() => createEmitter());
22
+ return emitter;
23
+ }
24
+ //#endregion
25
+ //#region src/ws-context/ws-events.ts
26
+ function createWsEventsContext() {
27
+ return createContext(null);
28
+ }
29
+ function createUseWsEvents(EmitterCtx) {
30
+ function useWsEvents(type, handler) {
31
+ const emitter = useContext(EmitterCtx);
32
+ if (!emitter) throw new Error("useWsEvents 必須包在對應的 WsProvider 內");
33
+ const handlerRef = useRef(handler);
34
+ handlerRef.current = handler;
35
+ useEffect(() => {
36
+ return emitter.on(type, ((...args) => {
37
+ handlerRef.current(...args);
38
+ }));
39
+ }, [type, emitter]);
40
+ }
41
+ return useWsEvents;
42
+ }
43
+ //#endregion
44
+ //#region src/ws-context/liveness/resolve-ping.ts
45
+ function resolvePingPayload(ping) {
46
+ return typeof ping === "function" ? ping() : ping;
47
+ }
48
+ //#endregion
49
+ //#region src/ws-context/liveness/controller.ts
50
+ function createLivenessController(options, onTimeout) {
51
+ const { intervalMs, timeoutMs, isPong } = options;
52
+ let intervalId = null;
53
+ let timeoutId = null;
54
+ let sendPingRef = null;
55
+ function clearTimeoutTimer() {
56
+ if (timeoutId != null) {
57
+ clearTimeout(timeoutId);
58
+ timeoutId = null;
59
+ }
60
+ }
61
+ function armTimeout() {
62
+ clearTimeoutTimer();
63
+ timeoutId = setTimeout(onTimeout, timeoutMs);
64
+ }
65
+ function tick() {
66
+ sendPingRef?.();
67
+ armTimeout();
68
+ }
69
+ return {
70
+ start(sendPing) {
71
+ sendPingRef = sendPing;
72
+ tick();
73
+ intervalId = setInterval(tick, intervalMs);
74
+ },
75
+ stop() {
76
+ if (intervalId != null) {
77
+ clearInterval(intervalId);
78
+ intervalId = null;
79
+ }
80
+ clearTimeoutTimer();
81
+ sendPingRef = null;
82
+ },
83
+ onMessage(data) {
84
+ if (isPong(data)) clearTimeoutTimer();
85
+ }
86
+ };
87
+ }
88
+ function createPingSender(ping, sendJson) {
89
+ return () => {
90
+ sendJson(resolvePingPayload(ping));
91
+ };
92
+ }
93
+ //#endregion
94
+ //#region src/ws-context/liveness/liveness.ts
95
+ const DISABLED_LIVENESS = {
96
+ start() {},
97
+ stop() {},
98
+ onMessage() {}
99
+ };
100
+ function createLiveness(options, getActiveSocket) {
101
+ let controller = null;
102
+ return {
103
+ start(ws) {
104
+ controller?.stop();
105
+ controller = createLivenessController(options, () => {
106
+ const current = getActiveSocket();
107
+ if (current?.readyState === WebSocket.OPEN) current.close();
108
+ });
109
+ const sendPing = createPingSender(options.ping, (data) => {
110
+ if (ws.readyState !== WebSocket.OPEN) return false;
111
+ ws.send(JSON.stringify(data));
112
+ return true;
113
+ });
114
+ controller.start(sendPing);
115
+ },
116
+ stop() {
117
+ controller?.stop();
118
+ controller = null;
119
+ },
120
+ onMessage(data) {
121
+ controller?.onMessage(data);
122
+ }
123
+ };
124
+ }
125
+ function useLiveness(options, getActiveSocket) {
126
+ const [session] = useState(() => options ? createLiveness(options, getActiveSocket) : DISABLED_LIVENESS);
127
+ return session;
128
+ }
129
+ //#endregion
130
+ //#region src/ws-context/outgoing-queue.ts
131
+ function createOutgoingQueue(max) {
132
+ let items = [];
133
+ return {
134
+ enqueue(data) {
135
+ if (max <= 0 || items.length >= max) return false;
136
+ items.push(data);
137
+ return true;
138
+ },
139
+ clear() {
140
+ items = [];
141
+ },
142
+ flush(send) {
143
+ const queued = items;
144
+ items = [];
145
+ for (const data of queued) send(data);
146
+ }
147
+ };
148
+ }
149
+ function useOutgoingQueue(max) {
150
+ const [queue] = useState(() => createOutgoingQueue(max));
151
+ return queue;
152
+ }
153
+ //#endregion
154
+ //#region src/ws-context/store.ts
155
+ function createStore(initialState) {
156
+ let state = initialState;
157
+ const listeners = /* @__PURE__ */ new Set();
158
+ return {
159
+ getState: () => state,
160
+ getInitialState: () => initialState,
161
+ setState: (partial) => {
162
+ const nextPartial = typeof partial === "function" ? partial(state) : partial;
163
+ if (Object.is(nextPartial, state)) return;
164
+ const prev = state;
165
+ state = Object.assign({}, state, nextPartial);
166
+ for (const listener of listeners) listener(state, prev);
167
+ },
168
+ subscribe: (listener) => {
169
+ listeners.add(listener);
170
+ return () => {
171
+ listeners.delete(listener);
172
+ };
173
+ }
174
+ };
175
+ }
176
+ //#endregion
177
+ //#region src/ws-context/use-store.ts
178
+ function useStore(store, selector) {
179
+ return useSyncExternalStore(store.subscribe, () => selector(store.getState()), () => selector(store.getInitialState()));
180
+ }
181
+ //#endregion
182
+ //#region src/ws-context/ws-store.ts
183
+ function createWsStore(init = "idle") {
184
+ return createStore({ status: init });
185
+ }
186
+ /** 每個 `WsProvider` 各有一份 {@link WsState} store */
187
+ function useWsStoreApi() {
188
+ const [store] = useState(() => createWsStore());
189
+ return store;
190
+ }
191
+ function createWsStoreContext() {
192
+ return createContext(null);
193
+ }
194
+ /** 訂閱 {@link WsState};建議以 selector 只取需要的連線層欄位。 */
195
+ function createUseWsStore(StoreCtx) {
196
+ function useWsStore(selector) {
197
+ const store = useContext(StoreCtx);
198
+ if (!store) throw new Error("useWsStore 必須包在對應的 WsProvider 內");
199
+ return useStore(store, selector ?? ((state) => state));
200
+ }
201
+ return useWsStore;
202
+ }
203
+ //#endregion
204
+ //#region src/ws-context/ws-actions.ts
205
+ function createWsActionsContext() {
206
+ return createContext(null);
207
+ }
208
+ function createUseWsActions(ActionsCtx) {
209
+ function useWsActions() {
210
+ const value = useContext(ActionsCtx);
211
+ if (!value) throw new Error("useWsActions 必須包在對應的 WsProvider 內");
212
+ return value;
213
+ }
214
+ return useWsActions;
215
+ }
216
+ //#endregion
217
+ //#region src/ws-context/socket.ts
218
+ function detachAndClose(ws) {
219
+ ws.onopen = null;
220
+ ws.onmessage = null;
221
+ ws.onerror = null;
222
+ ws.onclose = null;
223
+ if (ws.readyState < WebSocket.CLOSING) ws.close();
224
+ }
225
+ function clearReconnectTimer(timerRef) {
226
+ if (timerRef.current != null) {
227
+ clearTimeout(timerRef.current);
228
+ timerRef.current = null;
229
+ }
230
+ }
231
+ function clientCloseEvent(reason) {
232
+ return new CloseEvent("close", {
233
+ code: 1e3,
234
+ reason,
235
+ wasClean: true
236
+ });
237
+ }
238
+ //#endregion
239
+ //#region src/ws-context/index.tsx
240
+ function defaultParse(data) {
241
+ if (typeof data !== "string") return data;
242
+ try {
243
+ return JSON.parse(data);
244
+ } catch {
245
+ return data;
246
+ }
247
+ }
248
+ /**
249
+ * @example
250
+ * ```ts
251
+ * export const { WsProvider, useWsActions, useWsStore, useWsEvents } =
252
+ * createWsContext({ url: "ws://localhost:8080" });
253
+ * ```
254
+ */
255
+ function createWsContext(options) {
256
+ const { url, protocols, autoConnect = true, reconnectMs = 0, outgoingQueueMax = 0, parse = defaultParse, liveness } = options;
257
+ const ActionsCtx = createWsActionsContext();
258
+ const useWsActions = createUseWsActions(ActionsCtx);
259
+ const StoreCtx = createWsStoreContext();
260
+ const useWsStore = createUseWsStore(StoreCtx);
261
+ const EmitterCtx = createWsEventsContext();
262
+ const useWsEvents = createUseWsEvents(EmitterCtx);
263
+ function WsProvider({ children }) {
264
+ const wsRef = useRef(null);
265
+ const store = useWsStoreApi();
266
+ const emitter = useEmitter();
267
+ const intentionalCloseRef = useRef(false);
268
+ const reconnectTimerRef = useRef(null);
269
+ const outgoingQueue = useOutgoingQueue(outgoingQueueMax);
270
+ const livenessSession = useLiveness(liveness, () => wsRef.current);
271
+ const setStatus = useCallback((status) => {
272
+ store.setState({ status });
273
+ }, [store]);
274
+ const getStatus = useCallback(() => store.getState().status, [store]);
275
+ const disconnect = useCallback(() => {
276
+ intentionalCloseRef.current = true;
277
+ clearReconnectTimer(reconnectTimerRef);
278
+ livenessSession.stop();
279
+ outgoingQueue.clear();
280
+ const ws = wsRef.current;
281
+ wsRef.current = null;
282
+ if (ws) {
283
+ detachAndClose(ws);
284
+ setStatus("closed");
285
+ emitter.emit("close", clientCloseEvent("client disconnect"));
286
+ } else setStatus("closed");
287
+ }, [
288
+ setStatus,
289
+ emitter,
290
+ outgoingQueue,
291
+ livenessSession
292
+ ]);
293
+ const connect = useCallback(() => {
294
+ if (typeof window === "undefined") return;
295
+ clearReconnectTimer(reconnectTimerRef);
296
+ intentionalCloseRef.current = false;
297
+ livenessSession.stop();
298
+ const prev = wsRef.current;
299
+ if (prev) {
300
+ wsRef.current = null;
301
+ detachAndClose(prev);
302
+ emitter.emit("close", clientCloseEvent("reconnect"));
303
+ }
304
+ setStatus("connecting");
305
+ const ws = protocols ? new WebSocket(url, protocols) : new WebSocket(url);
306
+ wsRef.current = ws;
307
+ ws.onopen = (event) => {
308
+ if (wsRef.current !== ws) return;
309
+ setStatus("open");
310
+ outgoingQueue.flush((data) => ws.send(data));
311
+ livenessSession.start(ws);
312
+ emitter.emit("open", event);
313
+ };
314
+ ws.onmessage = (event) => {
315
+ if (wsRef.current !== ws) return;
316
+ const data = parse(event.data);
317
+ livenessSession.onMessage(data);
318
+ emitter.emit("message", data, event);
319
+ };
320
+ ws.onerror = (event) => {
321
+ if (wsRef.current !== ws) return;
322
+ emitter.emit("error", event);
323
+ };
324
+ ws.onclose = (event) => {
325
+ if (wsRef.current === ws) wsRef.current = null;
326
+ livenessSession.stop();
327
+ setStatus("closed");
328
+ emitter.emit("close", event);
329
+ if (intentionalCloseRef.current || reconnectMs <= 0) return;
330
+ reconnectTimerRef.current = setTimeout(() => {
331
+ connect();
332
+ }, reconnectMs);
333
+ };
334
+ }, [
335
+ setStatus,
336
+ emitter,
337
+ outgoingQueue,
338
+ livenessSession
339
+ ]);
340
+ useEffect(() => {
341
+ if (autoConnect) connect();
342
+ return () => {
343
+ intentionalCloseRef.current = true;
344
+ clearReconnectTimer(reconnectTimerRef);
345
+ livenessSession.stop();
346
+ outgoingQueue.clear();
347
+ const ws = wsRef.current;
348
+ wsRef.current = null;
349
+ if (ws) {
350
+ detachAndClose(ws);
351
+ emitter.emit("close", clientCloseEvent("provider unmount"));
352
+ }
353
+ };
354
+ }, [
355
+ connect,
356
+ emitter,
357
+ outgoingQueue,
358
+ livenessSession
359
+ ]);
360
+ const send = useCallback((data) => {
361
+ const ws = wsRef.current;
362
+ if (ws && ws.readyState === WebSocket.OPEN) {
363
+ ws.send(data);
364
+ return true;
365
+ }
366
+ return outgoingQueue.enqueue(data);
367
+ }, [outgoingQueue]);
368
+ const sendJson = useCallback((data) => send(JSON.stringify(data)), [send]);
369
+ const actions = useMemo(() => ({
370
+ send,
371
+ sendJson,
372
+ connect,
373
+ disconnect,
374
+ getStatus
375
+ }), [
376
+ send,
377
+ sendJson,
378
+ connect,
379
+ disconnect,
380
+ getStatus
381
+ ]);
382
+ return /* @__PURE__ */ jsx(ActionsCtx.Provider, {
383
+ value: actions,
384
+ children: /* @__PURE__ */ jsx(StoreCtx.Provider, {
385
+ value: store,
386
+ children: /* @__PURE__ */ jsx(EmitterCtx.Provider, {
387
+ value: emitter,
388
+ children
389
+ })
390
+ })
391
+ });
392
+ }
393
+ return {
394
+ WsProvider,
395
+ useWsActions,
396
+ useWsStore,
397
+ useWsEvents
398
+ };
399
+ }
400
+ //#endregion
401
+ export { createWsContext };
402
+
403
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/ws-context/emitter.ts","../src/ws-context/ws-events.ts","../src/ws-context/liveness/resolve-ping.ts","../src/ws-context/liveness/controller.ts","../src/ws-context/liveness/liveness.ts","../src/ws-context/outgoing-queue.ts","../src/ws-context/store.ts","../src/ws-context/use-store.ts","../src/ws-context/ws-store.ts","../src/ws-context/ws-actions.ts","../src/ws-context/socket.ts","../src/ws-context/index.tsx"],"sourcesContent":["// Typed event emitter。\n// 執行期邏輯對齊 nanoevents 的 createNanoEvents(幾乎逐行相同);型別為本套件收斂版。\n// Project: nanoevents — https://github.com/ai/nanoevents\n// Author: Andrey Sitnik — https://github.com/ai\n// License: MIT — https://github.com/ai/nanoevents/blob/main/LICENSE\n// Source:\n// - https://github.com/ai/nanoevents/blob/main/index.js\n// - https://github.com/ai/nanoevents/blob/main/index.d.ts\n// Modifications: 內嵌以達成零 runtime 依賴;新增 useEmitter(React useState 包裝)。\n\nimport { useState } from \"react\";\n\nexport interface Emitter<\n Events extends { [E in keyof Events]: (...args: never[]) => void },\n> {\n events: { [E in keyof Events]?: Array<Events[E]> };\n emit<E extends keyof Events>(event: E, ...args: Parameters<Events[E]>): void;\n on<E extends keyof Events>(event: E, cb: Events[E]): () => void;\n}\n\nexport function createEmitter<\n Events extends { [E in keyof Events]: (...args: never[]) => void },\n>(): Emitter<Events> {\n return {\n events: {},\n emit(event, ...args) {\n const callbacks = this.events[event] || [];\n for (let i = 0, len = callbacks.length; i < len; i++) {\n callbacks[i]!(...args);\n }\n },\n on(event, cb) {\n (this.events[event] ||= []).push(cb);\n return () => {\n this.events[event] = this.events[event]?.filter((fn) => fn !== cb);\n };\n },\n };\n}\n\nexport function useEmitter<\n Events extends { [E in keyof Events]: (...args: never[]) => void },\n>(): Emitter<Events> {\n const [emitter] = useState(() => createEmitter<Events>());\n return emitter;\n}\n","import {\n createContext,\n useContext,\n useEffect,\n useRef,\n type Context,\n} from \"react\";\nimport type { Emitter } from \"./emitter\";\nimport type { WsEvents } from \"./types\";\n\nexport function createWsEventsContext() {\n return createContext<Emitter<WsEvents> | null>(null);\n}\n\nexport function createUseWsEvents(\n EmitterCtx: Context<Emitter<WsEvents> | null>,\n) {\n function useWsEvents<E extends keyof WsEvents>(\n type: E,\n handler: WsEvents[E],\n ): void {\n const emitter = useContext(EmitterCtx);\n if (!emitter) {\n throw new Error(\"useWsEvents 必須包在對應的 WsProvider 內\");\n }\n\n const handlerRef = useRef(handler);\n handlerRef.current = handler;\n\n useEffect(() => {\n return emitter.on(type, ((...args: never[]) => {\n (handlerRef.current as (...a: never[]) => void)(...args);\n }) as WsEvents[E]);\n }, [type, emitter]);\n }\n\n return useWsEvents;\n}\n","import type { LivenessOptions } from \"./types\";\n\nexport function resolvePingPayload(ping: LivenessOptions[\"ping\"]): unknown {\n return typeof ping === \"function\" ? ping() : ping;\n}\n","import { resolvePingPayload } from \"./resolve-ping\";\nimport type { LivenessOptions } from \"./types\";\n\nexport interface LivenessController {\n /** 開始探活 */\n start: (sendPing: () => void) => void;\n /** 停止探活 */\n stop: () => void;\n /** 收到訊息 */\n onMessage: (data: unknown) => void;\n}\n\nexport function createLivenessController(\n options: LivenessOptions,\n onTimeout: () => void,\n): LivenessController {\n const { intervalMs, timeoutMs, isPong } = options;\n\n let intervalId: ReturnType<typeof setInterval> | null = null;\n let timeoutId: ReturnType<typeof setTimeout> | null = null;\n let sendPingRef: (() => void) | null = null;\n\n function clearTimeoutTimer(): void {\n if (timeoutId != null) {\n clearTimeout(timeoutId);\n timeoutId = null;\n }\n }\n\n function armTimeout(): void {\n clearTimeoutTimer();\n timeoutId = setTimeout(onTimeout, timeoutMs);\n }\n\n function tick(): void {\n sendPingRef?.();\n armTimeout();\n }\n\n return {\n start(sendPing) {\n sendPingRef = sendPing;\n tick();\n intervalId = setInterval(tick, intervalMs);\n },\n\n stop() {\n if (intervalId != null) {\n clearInterval(intervalId);\n intervalId = null;\n }\n clearTimeoutTimer();\n sendPingRef = null;\n },\n\n onMessage(data) {\n if (isPong(data)) clearTimeoutTimer();\n },\n };\n}\n\nexport function createPingSender(\n ping: LivenessOptions[\"ping\"],\n sendJson: (data: unknown) => boolean,\n): () => void {\n return () => {\n sendJson(resolvePingPayload(ping));\n };\n}\n","import { useState } from \"react\";\nimport {\n createLivenessController,\n createPingSender,\n type LivenessController,\n} from \"./controller\";\nimport type { LivenessOptions } from \"./types\";\n\nexport interface Liveness {\n /** 開始探活 */\n start: (ws: WebSocket) => void;\n /** 停止探活 */\n stop: () => void;\n /** 收到訊息 */\n onMessage: (data: unknown) => void;\n}\n\nconst DISABLED_LIVENESS: Liveness = {\n start() {},\n stop() {},\n onMessage() {},\n};\n\nexport function createLiveness(\n options: LivenessOptions,\n getActiveSocket: () => WebSocket | null,\n): Liveness {\n let controller: LivenessController | null = null;\n\n return {\n start(ws) {\n controller?.stop();\n controller = createLivenessController(options, () => {\n const current = getActiveSocket();\n if (current?.readyState === WebSocket.OPEN) current.close();\n });\n const sendPing = createPingSender(options.ping, (data) => {\n if (ws.readyState !== WebSocket.OPEN) return false;\n ws.send(JSON.stringify(data));\n return true;\n });\n controller.start(sendPing);\n },\n\n stop() {\n controller?.stop();\n controller = null;\n },\n\n onMessage(data) {\n controller?.onMessage(data);\n },\n };\n}\n\nexport function useLiveness(\n options: LivenessOptions | undefined,\n getActiveSocket: () => WebSocket | null,\n): Liveness {\n const [session] = useState(() =>\n options ? createLiveness(options, getActiveSocket) : DISABLED_LIVENESS,\n );\n return session;\n}\n","import { useState } from \"react\";\n\nexport type OutgoingData = Parameters<WebSocket[\"send\"]>[0];\n\nexport interface OutgoingQueue {\n /** 列隊 */\n enqueue: (data: OutgoingData) => boolean;\n /** 清空 */\n clear: () => void;\n /** 依序送出後清空 */\n flush: (send: (data: OutgoingData) => void) => void;\n}\n\nexport function createOutgoingQueue(max: number): OutgoingQueue {\n let items: OutgoingData[] = [];\n\n return {\n enqueue(data) {\n if (max <= 0 || items.length >= max) return false;\n items.push(data);\n return true;\n },\n clear() {\n items = [];\n },\n flush(send) {\n const queued = items;\n items = [];\n for (const data of queued) send(data);\n },\n };\n}\n\nexport function useOutgoingQueue(max: number): OutgoingQueue {\n const [queue] = useState(() => createOutgoingQueue(max));\n return queue;\n}\n","// 精簡外部 store:只保留本套件需要的 getState / setState / subscribe / getInitialState。\n// 靈感與行為對齊 zustand/vanilla(非完整搬移;無 middleware、無 replace、無 initializer factory)。\n// Project: zustand — https://github.com/pmndrs/zustand\n// Author: pmndrs (Poimandres) — https://github.com/pmndrs\n// License: MIT — https://github.com/pmndrs/zustand/blob/main/LICENSE\n// Source: https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts\n// Modifications: 內嵌子集以達成零 runtime 依賴。\n\nexport type StoreApi<State> = {\n getState: () => State;\n getInitialState: () => State;\n setState: (\n partial: Partial<State> | ((state: State) => Partial<State>),\n ) => void;\n subscribe: (listener: (state: State, prev: State) => void) => () => void;\n};\n\nexport function createStore<State extends object>(\n initialState: State,\n): StoreApi<State> {\n let state = initialState;\n const listeners = new Set<(state: State, prev: State) => void>();\n\n return {\n getState: () => state,\n getInitialState: () => initialState,\n setState: (partial) => {\n const nextPartial =\n typeof partial === \"function\" ? partial(state) : partial;\n if (Object.is(nextPartial, state)) return;\n const prev = state;\n state = Object.assign({}, state, nextPartial);\n for (const listener of listeners) listener(state, prev);\n },\n subscribe: (listener) => {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n };\n}\n","// 訂閱外部 store(selector + useSyncExternalStore)。\n// 靈感來自 zustand/react 的 useStore(非完整搬移;selector 必填、無 useDebugValue)。\n// Project: zustand — https://github.com/pmndrs/zustand\n// Author: pmndrs (Poimandres) — https://github.com/pmndrs\n// License: MIT — https://github.com/pmndrs/zustand/blob/main/LICENSE\n// Source: https://github.com/pmndrs/zustand/blob/main/src/react.ts\n// Modifications: 內嵌以達成零 runtime 依賴。\n// 用途:訂閱 WsState(連線健康/佇列/重連);訊息不走此 store。\n\nimport { useSyncExternalStore } from \"react\";\nimport type { StoreApi } from \"./store\";\n\nexport function useStore<State, Selected>(\n store: StoreApi<State>,\n selector: (state: State) => Selected,\n): Selected {\n return useSyncExternalStore(\n store.subscribe,\n () => selector(store.getState()),\n () => selector(store.getInitialState()),\n );\n}\n","import { createContext, useContext, useState, type Context } from \"react\";\nimport { createStore, type StoreApi } from \"./store\";\nimport { useStore } from \"./use-store\";\n\n/**\n * 連線生命週期狀態(`WsState` 的一環)。\n *\n * - `idle` — 尚未連線\n * - `connecting` — 連線中\n * - `open` — 已連線\n * - `closed` — 已斷線\n *\n * 錯誤用 `useWsEvents(\"error\")`;不另設 error status。\n */\nexport type WsStatus = \"idle\" | \"connecting\" | \"open\" | \"closed\";\n\n/**\n * 可訂閱的連線層 state(低頻更新)。\n *\n * 只放:**連線健康**、**outbound 佇列**、**重連** 等連線生命週期資訊。\n * 不放:訊息 payload、訊息歷史、業務資料(請用 `useWsEvents` 或自行管理 state)。\n *\n * 未來可能擴充例如 `reconnectAttempt`、`pendingCount`;新增欄位時請維持低頻、可 selector 訂閱。\n */\nexport type WsState = {\n /** 連線生命週期狀態 */\n status: WsStatus;\n};\n\nexport type WsStoreApi = StoreApi<WsState>;\n\nexport function createWsStore(init: WsStatus = \"idle\"): WsStoreApi {\n return createStore<WsState>({ status: init });\n}\n\n/** 每個 `WsProvider` 各有一份 {@link WsState} store */\nexport function useWsStoreApi(): WsStoreApi {\n const [store] = useState(() => createWsStore());\n return store;\n}\n\nexport function createWsStoreContext() {\n return createContext<WsStoreApi | null>(null);\n}\n\n/** 訂閱 {@link WsState};建議以 selector 只取需要的連線層欄位。 */\nexport function createUseWsStore(StoreCtx: Context<WsStoreApi | null>) {\n function useWsStore(): WsState;\n function useWsStore<T>(selector: (state: WsState) => T): T;\n function useWsStore<T>(selector?: (state: WsState) => T): T {\n const store = useContext(StoreCtx);\n if (!store) {\n throw new Error(\"useWsStore 必須包在對應的 WsProvider 內\");\n }\n const select = selector ?? ((state: WsState) => state as T);\n return useStore(store, select);\n }\n\n return useWsStore;\n}\n","import { createContext, useContext, type Context } from \"react\";\nimport type { WsContextValue } from \"./types\";\n\nexport function createWsActionsContext() {\n return createContext<WsContextValue | null>(null);\n}\n\nexport function createUseWsActions(ActionsCtx: Context<WsContextValue | null>) {\n function useWsActions(): WsContextValue {\n const value = useContext(ActionsCtx);\n if (!value) {\n throw new Error(\"useWsActions 必須包在對應的 WsProvider 內\");\n }\n return value;\n }\n\n return useWsActions;\n}\n","import type { RefObject } from \"react\";\n\nexport function detachAndClose(ws: WebSocket): void {\n ws.onopen = null;\n ws.onmessage = null;\n ws.onerror = null;\n ws.onclose = null;\n if (ws.readyState < WebSocket.CLOSING) ws.close();\n}\n\nexport function clearReconnectTimer(\n timerRef: RefObject<ReturnType<typeof setTimeout> | null>,\n): void {\n if (timerRef.current != null) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n}\n\nexport function clientCloseEvent(reason: string): CloseEvent {\n return new CloseEvent(\"close\", {\n code: 1000,\n reason,\n wasClean: true,\n });\n}\n","import {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n type PropsWithChildren,\n} from \"react\";\nimport { useEmitter } from \"./emitter\";\nimport { createUseWsEvents, createWsEventsContext } from \"./ws-events\";\nimport type { CreateWsContextOptions, WsContextValue, WsEvents } from \"./types\";\nimport { useLiveness } from \"./liveness/liveness\";\nimport { useOutgoingQueue } from \"./outgoing-queue\";\nimport {\n createUseWsStore,\n createWsStoreContext,\n useWsStoreApi,\n type WsStatus,\n} from \"./ws-store\";\nimport { createUseWsActions, createWsActionsContext } from \"./ws-actions\";\nimport {\n clearReconnectTimer,\n clientCloseEvent,\n detachAndClose,\n} from \"./socket\";\n\nexport type { CreateWsContextOptions, WsContextValue, WsEvents } from \"./types\";\n\nfunction defaultParse(data: MessageEvent[\"data\"]): unknown {\n if (typeof data !== \"string\") return data;\n try {\n return JSON.parse(data) as unknown;\n } catch {\n return data;\n }\n}\n\n/**\n * @example\n * ```ts\n * export const { WsProvider, useWsActions, useWsStore, useWsEvents } =\n * createWsContext({ url: \"ws://localhost:8080\" });\n * ```\n */\nexport function createWsContext(options: CreateWsContextOptions) {\n const {\n url,\n protocols,\n autoConnect = true,\n reconnectMs = 0,\n outgoingQueueMax = 0,\n parse = defaultParse,\n liveness,\n } = options;\n\n const ActionsCtx = createWsActionsContext();\n const useWsActions = createUseWsActions(ActionsCtx);\n const StoreCtx = createWsStoreContext();\n const useWsStore = createUseWsStore(StoreCtx);\n const EmitterCtx = createWsEventsContext();\n const useWsEvents = createUseWsEvents(EmitterCtx);\n\n function WsProvider({ children }: PropsWithChildren) {\n const wsRef = useRef<WebSocket | null>(null);\n const store = useWsStoreApi();\n const emitter = useEmitter<WsEvents>();\n const intentionalCloseRef = useRef(false);\n const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(\n null,\n );\n const outgoingQueue = useOutgoingQueue(outgoingQueueMax);\n const livenessSession = useLiveness(liveness, () => wsRef.current);\n\n const setStatus = useCallback(\n (status: WsStatus) => {\n store.setState({ status });\n },\n [store],\n );\n\n const getStatus = useCallback<WsContextValue[\"getStatus\"]>(\n () => store.getState().status,\n [store],\n );\n\n const disconnect = useCallback<WsContextValue[\"disconnect\"]>(() => {\n intentionalCloseRef.current = true;\n clearReconnectTimer(reconnectTimerRef);\n livenessSession.stop();\n outgoingQueue.clear();\n const ws = wsRef.current;\n wsRef.current = null;\n if (ws) {\n detachAndClose(ws);\n setStatus(\"closed\");\n emitter.emit(\"close\", clientCloseEvent(\"client disconnect\"));\n } else {\n setStatus(\"closed\");\n }\n }, [setStatus, emitter, outgoingQueue, livenessSession]);\n\n const connect = useCallback<WsContextValue[\"connect\"]>(() => {\n if (typeof window === \"undefined\") return;\n\n clearReconnectTimer(reconnectTimerRef);\n intentionalCloseRef.current = false;\n livenessSession.stop();\n\n const prev = wsRef.current;\n if (prev) {\n wsRef.current = null;\n detachAndClose(prev);\n emitter.emit(\"close\", clientCloseEvent(\"reconnect\"));\n }\n\n setStatus(\"connecting\");\n\n const ws = protocols ? new WebSocket(url, protocols) : new WebSocket(url);\n wsRef.current = ws;\n\n ws.onopen = (event) => {\n if (wsRef.current !== ws) return;\n setStatus(\"open\");\n outgoingQueue.flush((data) => ws.send(data));\n livenessSession.start(ws);\n emitter.emit(\"open\", event);\n };\n\n ws.onmessage = (event) => {\n if (wsRef.current !== ws) return;\n const data = parse(event.data);\n livenessSession.onMessage(data);\n emitter.emit(\"message\", data, event);\n };\n\n ws.onerror = (event) => {\n if (wsRef.current !== ws) return;\n emitter.emit(\"error\", event);\n };\n\n ws.onclose = (event) => {\n if (wsRef.current === ws) wsRef.current = null;\n livenessSession.stop();\n setStatus(\"closed\");\n emitter.emit(\"close\", event);\n\n if (intentionalCloseRef.current || reconnectMs <= 0) return;\n // ponytail: 固定間隔重連,無 backoff;之後可換成指數退避\n reconnectTimerRef.current = setTimeout(() => {\n connect();\n }, reconnectMs);\n };\n }, [setStatus, emitter, outgoingQueue, livenessSession]);\n\n useEffect(() => {\n if (autoConnect) connect();\n return () => {\n intentionalCloseRef.current = true;\n clearReconnectTimer(reconnectTimerRef);\n livenessSession.stop();\n outgoingQueue.clear();\n const ws = wsRef.current;\n wsRef.current = null;\n if (ws) {\n detachAndClose(ws);\n emitter.emit(\"close\", clientCloseEvent(\"provider unmount\"));\n }\n };\n }, [connect, emitter, outgoingQueue, livenessSession]);\n\n const send = useCallback<WsContextValue[\"send\"]>(\n (data) => {\n const ws = wsRef.current;\n if (ws && ws.readyState === WebSocket.OPEN) {\n ws.send(data);\n return true;\n }\n return outgoingQueue.enqueue(data);\n },\n [outgoingQueue],\n );\n\n const sendJson = useCallback<WsContextValue[\"sendJson\"]>(\n (data) => send(JSON.stringify(data)),\n [send],\n );\n\n const actions = useMemo<WsContextValue>(\n () => ({\n send,\n sendJson,\n connect,\n disconnect,\n getStatus,\n }),\n [send, sendJson, connect, disconnect, getStatus],\n );\n\n return (\n <ActionsCtx.Provider value={actions}>\n <StoreCtx.Provider value={store}>\n <EmitterCtx.Provider value={emitter}>{children}</EmitterCtx.Provider>\n </StoreCtx.Provider>\n </ActionsCtx.Provider>\n );\n }\n\n return {\n WsProvider,\n useWsActions,\n useWsStore,\n useWsEvents,\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ //#region src/stall/constants.d.ts
2
+ /** 客戶端停滯控制訊息的 `type` 欄位值 */
3
+ declare const STALL_MESSAGE_TYPE: "STALL";
4
+ /** 伺服器停滯確認的 `type` 欄位值 */
5
+ declare const STALL_ACK_TYPE: "STALL_ACK";
6
+ //#endregion
7
+ //#region src/stall/types.d.ts
8
+ /**
9
+ * 停滯控制
10
+ *
11
+ * - `stall` 進入停滯
12
+ * - `release` 恢復正常
13
+ */
14
+ type StallAction = "stall" | "release";
15
+ /** 客戶端經 WebSocket 送出的停滯控制訊息 */
16
+ interface StallMessage {
17
+ type: typeof STALL_MESSAGE_TYPE;
18
+ /** 控制動作 */
19
+ action: StallAction;
20
+ }
21
+ /** 伺服器回覆的停滯狀態確認 */
22
+ interface StallAck {
23
+ type: typeof STALL_ACK_TYPE;
24
+ /** 本次 ack 對應的控制動作 */
25
+ action: StallAction;
26
+ /** 回覆當下是否處於停滯中 */
27
+ active: boolean;
28
+ }
29
+ //#endregion
30
+ //#region src/stall/messages.d.ts
31
+ /** 建立可 `sendJson` 的停滯控制訊息 */
32
+ declare function createStallMessage(action: StallAction): StallMessage;
33
+ /**
34
+ * 從 `useWsEvents("message", …)` 的 `data` 解析停滯訊息。
35
+ * 格式不符回傳 `null`。
36
+ */
37
+ declare function parseStallMessage(data: unknown): StallMessage | null;
38
+ //#endregion
39
+ export { STALL_ACK_TYPE, STALL_MESSAGE_TYPE, type StallAck, type StallAction, type StallMessage, createStallMessage, parseStallMessage };
40
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/stall/constants.ts","../../src/stall/types.ts","../../src/stall/messages.ts"],"mappings":";;cACa;;cAGA;;;;;;;;;KCID;;UAGK;EACf,aAAa;;EAEb,QAAQ;;;UAIO;EACf,aAAa;;EAEb,QAAQ;;EAER;;;;;iBCnBc,mBAAmB,QAAQ,cAAc;;;;;iBAQzC,kBAAkB,gBAAgB"}
@@ -0,0 +1,32 @@
1
+ //#region src/stall/constants.ts
2
+ /** 客戶端停滯控制訊息的 `type` 欄位值 */
3
+ const STALL_MESSAGE_TYPE = "STALL";
4
+ /** 伺服器停滯確認的 `type` 欄位值 */
5
+ const STALL_ACK_TYPE = "STALL_ACK";
6
+ //#endregion
7
+ //#region src/stall/messages.ts
8
+ /** 建立可 `sendJson` 的停滯控制訊息 */
9
+ function createStallMessage(action) {
10
+ return {
11
+ type: STALL_MESSAGE_TYPE,
12
+ action
13
+ };
14
+ }
15
+ /**
16
+ * 從 `useWsEvents("message", …)` 的 `data` 解析停滯訊息。
17
+ * 格式不符回傳 `null`。
18
+ */
19
+ function parseStallMessage(data) {
20
+ if (typeof data !== "object" || data == null) return null;
21
+ const msg = data;
22
+ if (msg.type !== "STALL") return null;
23
+ if (msg.action === "stall" || msg.action === "release") return {
24
+ type: STALL_MESSAGE_TYPE,
25
+ action: msg.action
26
+ };
27
+ return null;
28
+ }
29
+ //#endregion
30
+ export { STALL_ACK_TYPE, STALL_MESSAGE_TYPE, createStallMessage, parseStallMessage };
31
+
32
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/stall/constants.ts","../../src/stall/messages.ts"],"sourcesContent":["/** 客戶端停滯控制訊息的 `type` 欄位值 */\nexport const STALL_MESSAGE_TYPE = \"STALL\" as const;\n\n/** 伺服器停滯確認的 `type` 欄位值 */\nexport const STALL_ACK_TYPE = \"STALL_ACK\" as const;\n","import { STALL_MESSAGE_TYPE } from \"./constants\";\nimport type { StallAction, StallMessage } from \"./types\";\n\n/** 建立可 `sendJson` 的停滯控制訊息 */\nexport function createStallMessage(action: StallAction): StallMessage {\n return { type: STALL_MESSAGE_TYPE, action };\n}\n\n/**\n * 從 `useWsEvents(\"message\", …)` 的 `data` 解析停滯訊息。\n * 格式不符回傳 `null`。\n */\nexport function parseStallMessage(data: unknown): StallMessage | null {\n if (typeof data !== \"object\" || data == null) return null;\n const msg = data as Record<string, unknown>;\n if (msg.type !== STALL_MESSAGE_TYPE) return null;\n if (msg.action === \"stall\" || msg.action === \"release\") {\n return { type: STALL_MESSAGE_TYPE, action: msg.action };\n }\n return null;\n}\n"],"mappings":";;AACA,MAAa,qBAAqB;;AAGlC,MAAa,iBAAiB;;;;ACA9B,SAAgB,mBAAmB,QAAmC;CACpE,OAAO;EAAE,MAAM;EAAoB;CAAO;AAC5C;;;;;AAMA,SAAgB,kBAAkB,MAAoC;CACpE,IAAI,OAAO,SAAS,YAAY,QAAQ,MAAM,OAAO;CACrD,MAAM,MAAM;CACZ,IAAI,IAAI,SAAA,SAA6B,OAAO;CAC5C,IAAI,IAAI,WAAW,WAAW,IAAI,WAAW,WAC3C,OAAO;EAAE,MAAM;EAAoB,QAAQ,IAAI;CAAO;CAExD,OAAO;AACT"}
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "react-ws-context",
3
+ "version": "0.1.0",
4
+ "description": "React WebSocket connection layer — separate hooks for actions, connection state, and message events to avoid unnecessary re-renders",
5
+ "keywords": [
6
+ "react",
7
+ "react-hooks",
8
+ "hooks",
9
+ "websocket",
10
+ "ws",
11
+ "context",
12
+ "client"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "GaiaYang (https://github.com/GaiaYang)",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/GaiaYang/react-ws.git",
19
+ "directory": "packages/react-ws"
20
+ },
21
+ "homepage": "https://github.com/GaiaYang/react-ws/tree/main/packages/react-ws#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/GaiaYang/react-ws/issues"
24
+ },
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "main": "./dist/index.mjs",
28
+ "types": "./dist/index.d.mts",
29
+ "exports": {
30
+ ".": {
31
+ "development": "./src/index.ts",
32
+ "types": "./dist/index.d.mts",
33
+ "import": "./dist/index.mjs",
34
+ "default": "./dist/index.mjs"
35
+ },
36
+ "./stall": {
37
+ "development": "./src/stall/index.ts",
38
+ "types": "./dist/stall/index.d.mts",
39
+ "import": "./dist/stall/index.mjs",
40
+ "default": "./dist/stall/index.mjs"
41
+ }
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "README.md",
46
+ "LICENSE",
47
+ "CHANGELOG.md"
48
+ ],
49
+ "peerDependencies": {
50
+ "react": ">=18"
51
+ },
52
+ "devDependencies": {
53
+ "@eslint/js": "^9.39.4",
54
+ "@testing-library/react": "^16.3.2",
55
+ "@types/react": "^19",
56
+ "@types/react-dom": "^19.2.3",
57
+ "eslint": "^9",
58
+ "eslint-config-prettier": "^10.1.8",
59
+ "eslint-plugin-react-hooks": "^7.1.1",
60
+ "jsdom": "^30.0.1",
61
+ "prettier": "^3.9.6",
62
+ "react": "19.2.8",
63
+ "react-dom": "19.2.8",
64
+ "tsdown": "^0.22.14",
65
+ "typescript": "^5",
66
+ "typescript-eslint": "^8.63.0",
67
+ "vitest": "^4.1.11"
68
+ },
69
+ "scripts": {
70
+ "build": "tsdown",
71
+ "dev": "tsdown --watch",
72
+ "lint": "eslint .",
73
+ "format": "prettier --write .",
74
+ "format:check": "prettier --check .",
75
+ "typecheck": "tsc --noEmit",
76
+ "test": "vitest run",
77
+ "prepublishOnly": "pnpm build"
78
+ }
79
+ }