react-ws-context 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -17,21 +17,24 @@ function createEmitter() {
17
17
  }
18
18
  };
19
19
  }
20
- function useEmitter() {
21
- const [emitter] = useState(() => createEmitter());
22
- return emitter;
23
- }
24
20
  //#endregion
25
21
  //#region src/ws-context/ws-events.ts
26
22
  function createWsEventsContext() {
27
23
  return createContext(null);
28
24
  }
29
- function createUseWsEvents(EmitterCtx) {
25
+ /** 每個 `WsProvider` 各有一份 event emitter */
26
+ function useWsEventsApi() {
27
+ const [emitter] = useState(() => createEmitter());
28
+ return emitter;
29
+ }
30
+ function createUseWsEvents(EventsCtx) {
30
31
  function useWsEvents(type, handler) {
31
- const emitter = useContext(EmitterCtx);
32
+ const emitter = useContext(EventsCtx);
32
33
  if (!emitter) throw new Error("useWsEvents 必須包在對應的 WsProvider 內");
33
34
  const handlerRef = useRef(handler);
34
- handlerRef.current = handler;
35
+ useEffect(() => {
36
+ handlerRef.current = handler;
37
+ });
35
38
  useEffect(() => {
36
39
  return emitter.on(type, ((...args) => {
37
40
  handlerRef.current(...args);
@@ -100,15 +103,16 @@ const DISABLED_LIVENESS = {
100
103
  function createLiveness(options, getActiveSocket) {
101
104
  let controller = null;
102
105
  return {
103
- start(ws) {
106
+ start() {
104
107
  controller?.stop();
105
108
  controller = createLivenessController(options, () => {
106
109
  const current = getActiveSocket();
107
110
  if (current?.readyState === WebSocket.OPEN) current.close();
108
111
  });
109
112
  const sendPing = createPingSender(options.ping, (data) => {
110
- if (ws.readyState !== WebSocket.OPEN) return false;
111
- ws.send(JSON.stringify(data));
113
+ const current = getActiveSocket();
114
+ if (!current || current.readyState !== WebSocket.OPEN) return false;
115
+ current.send(JSON.stringify(data));
112
116
  return true;
113
117
  });
114
118
  controller.start(sendPing);
@@ -160,7 +164,7 @@ function createStore(initialState) {
160
164
  getInitialState: () => initialState,
161
165
  setState: (partial) => {
162
166
  const nextPartial = typeof partial === "function" ? partial(state) : partial;
163
- if (Object.is(nextPartial, state)) return;
167
+ if (!hasPartialChanged(state, nextPartial)) return;
164
168
  const prev = state;
165
169
  state = Object.assign({}, state, nextPartial);
166
170
  for (const listener of listeners) listener(state, prev);
@@ -173,6 +177,10 @@ function createStore(initialState) {
173
177
  }
174
178
  };
175
179
  }
180
+ function hasPartialChanged(state, partial) {
181
+ for (const [key, value] of Object.entries(partial)) if (!Object.is(state[key], value)) return true;
182
+ return false;
183
+ }
176
184
  //#endregion
177
185
  //#region src/ws-context/use-store.ts
178
186
  function useStore(store, selector) {
@@ -183,19 +191,19 @@ function useStore(store, selector) {
183
191
  function createWsStore(init = "idle") {
184
192
  return createStore({
185
193
  status: init,
186
- phase: init === "open" ? "open" : init === "idle" ? "idle" : "connecting",
194
+ phase: init === "open" || init === "idle" ? init : "connecting",
187
195
  reconnectAttempt: 0,
188
196
  reconnectExhausted: false
189
197
  });
190
198
  }
199
+ function createWsStoreContext() {
200
+ return createContext(null);
201
+ }
191
202
  /** 每個 `WsProvider` 各有一份 {@link WsState} store */
192
203
  function useWsStoreApi() {
193
204
  const [store] = useState(() => createWsStore());
194
205
  return store;
195
206
  }
196
- function createWsStoreContext() {
197
- return createContext(null);
198
- }
199
207
  /** 訂閱 {@link WsState};建議以 selector 只取需要的連線層欄位。 */
200
208
  function createUseWsStore(StoreCtx) {
201
209
  function useWsStore(selector) {
@@ -207,6 +215,7 @@ function createUseWsStore(StoreCtx) {
207
215
  }
208
216
  //#endregion
209
217
  //#region src/ws-context/ws-actions.ts
218
+ /** actions 無獨立 instance;由 WsProvider 以 useMemo 組裝後注入 Context */
210
219
  function createWsActionsContext() {
211
220
  return createContext(null);
212
221
  }
@@ -311,16 +320,16 @@ function defaultParse(data) {
311
320
  */
312
321
  function createWsContext(options) {
313
322
  const { url, protocols, autoConnect = true, reconnectMs = 0, reconnectMax = 0, outgoingQueueMax = 0, parse = defaultParse, liveness } = options;
314
- const ActionsCtx = createWsActionsContext();
315
- const useWsActions = createUseWsActions(ActionsCtx);
316
323
  const StoreCtx = createWsStoreContext();
317
324
  const useWsStore = createUseWsStore(StoreCtx);
318
- const EmitterCtx = createWsEventsContext();
319
- const useWsEvents = createUseWsEvents(EmitterCtx);
325
+ const ActionsCtx = createWsActionsContext();
326
+ const useWsActions = createUseWsActions(ActionsCtx);
327
+ const EventsCtx = createWsEventsContext();
328
+ const useWsEvents = createUseWsEvents(EventsCtx);
320
329
  function WsProvider({ children }) {
321
330
  const wsRef = useRef(null);
322
331
  const store = useWsStoreApi();
323
- const emitter = useEmitter();
332
+ const emitter = useWsEventsApi();
324
333
  const reconnect = useReconnect(reconnectMs, reconnectMax, {
325
334
  getAttempt: () => store.getState().reconnectAttempt,
326
335
  setAttempt: (reconnectAttempt) => store.setState({ reconnectAttempt }),
@@ -328,33 +337,36 @@ function createWsContext(options) {
328
337
  });
329
338
  const outgoingQueue = useOutgoingQueue(outgoingQueueMax);
330
339
  const livenessSession = useLiveness(liveness, () => wsRef.current);
331
- const setStatus = useCallback((status) => {
332
- store.setState({ status });
333
- }, [store]);
334
- const setPhase = useCallback((phase) => {
335
- store.setState({ phase });
336
- }, [store]);
337
340
  const getStatus = useCallback(() => store.getState().status, [store]);
338
- const disconnect = useCallback(() => {
341
+ /**
342
+ * 主動斷線與 Provider unmount 共用 cleanup。
343
+ *
344
+ * 關閉 socket 時以 `reason` 寫入 synthetic `close` 事件:
345
+ * - `"client disconnect"` — `disconnect()`
346
+ * - `"provider unmount"` — `WsProvider` unmount
347
+ */
348
+ const teardown = useCallback((reason) => {
339
349
  reconnect.cancel();
340
350
  livenessSession.stop();
341
351
  outgoingQueue.clear();
342
- setPhase("idle");
352
+ store.setState({
353
+ phase: "idle",
354
+ status: "closed"
355
+ });
343
356
  const ws = wsRef.current;
344
357
  wsRef.current = null;
345
358
  if (ws) {
346
359
  detachAndClose(ws);
347
- setStatus("closed");
348
- emitter.emit("close", clientCloseEvent("client disconnect"));
349
- } else setStatus("closed");
360
+ emitter.emit("close", clientCloseEvent(reason));
361
+ }
350
362
  }, [
351
- setStatus,
352
- setPhase,
363
+ store,
353
364
  emitter,
354
365
  outgoingQueue,
355
366
  livenessSession,
356
367
  reconnect
357
368
  ]);
369
+ const disconnect = useCallback(() => teardown("client disconnect"), [teardown]);
358
370
  const connect = useCallback(() => {
359
371
  if (typeof window === "undefined") return;
360
372
  const fromReconnect = reconnect.onConnectBegin();
@@ -365,17 +377,21 @@ function createWsContext(options) {
365
377
  detachAndClose(prev);
366
378
  emitter.emit("close", clientCloseEvent("reconnect"));
367
379
  }
368
- setStatus("connecting");
369
- setPhase(fromReconnect ? "reconnecting" : "connecting");
380
+ store.setState({
381
+ status: "connecting",
382
+ phase: fromReconnect ? "reconnecting" : "connecting"
383
+ });
370
384
  const ws = protocols ? new WebSocket(url, protocols) : new WebSocket(url);
371
385
  wsRef.current = ws;
372
386
  ws.onopen = (event) => {
373
387
  if (wsRef.current !== ws) return;
374
388
  reconnect.onOpen();
375
- setStatus("open");
376
- setPhase("open");
389
+ store.setState({
390
+ status: "open",
391
+ phase: "open"
392
+ });
377
393
  outgoingQueue.flush((data) => ws.send(data));
378
- livenessSession.start(ws);
394
+ livenessSession.start();
379
395
  emitter.emit("open", event);
380
396
  };
381
397
  ws.onmessage = (event) => {
@@ -391,41 +407,25 @@ function createWsContext(options) {
391
407
  ws.onclose = (event) => {
392
408
  if (wsRef.current === ws) wsRef.current = null;
393
409
  livenessSession.stop();
394
- setStatus("closed");
410
+ const scheduled = reconnect.scheduleAfterClose();
411
+ const patch = { status: "closed" };
412
+ if (scheduled) patch.phase = "reconnecting";
413
+ else if (store.getState().phase !== "idle") patch.phase = "stopped";
414
+ store.setState(patch);
395
415
  emitter.emit("close", event);
396
- if (reconnect.scheduleAfterClose()) setPhase("reconnecting");
397
- else if (store.getState().phase !== "idle") setPhase("stopped");
398
416
  };
399
417
  }, [
400
- setStatus,
401
- setPhase,
418
+ store,
402
419
  emitter,
403
420
  outgoingQueue,
404
421
  livenessSession,
405
- reconnect,
406
- store
422
+ reconnect
407
423
  ]);
408
424
  reconnect.bindOnReconnect(connect);
409
425
  useEffect(() => {
410
426
  if (autoConnect) connect();
411
- return () => {
412
- reconnect.cancel();
413
- livenessSession.stop();
414
- outgoingQueue.clear();
415
- const ws = wsRef.current;
416
- wsRef.current = null;
417
- if (ws) {
418
- detachAndClose(ws);
419
- emitter.emit("close", clientCloseEvent("provider unmount"));
420
- }
421
- };
422
- }, [
423
- connect,
424
- emitter,
425
- outgoingQueue,
426
- livenessSession,
427
- reconnect
428
- ]);
427
+ return () => teardown("provider unmount");
428
+ }, [connect, teardown]);
429
429
  const send = useCallback((data) => {
430
430
  const ws = wsRef.current;
431
431
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -434,7 +434,13 @@ function createWsContext(options) {
434
434
  }
435
435
  return outgoingQueue.enqueue(data);
436
436
  }, [outgoingQueue]);
437
- const sendJson = useCallback((data) => send(JSON.stringify(data)), [send]);
437
+ const sendJson = useCallback((data) => {
438
+ try {
439
+ return send(JSON.stringify(data));
440
+ } catch {
441
+ return false;
442
+ }
443
+ }, [send]);
438
444
  const actions = useMemo(() => ({
439
445
  send,
440
446
  sendJson,
@@ -452,7 +458,7 @@ function createWsContext(options) {
452
458
  value: actions,
453
459
  children: /* @__PURE__ */ jsx(StoreCtx.Provider, {
454
460
  value: store,
455
- children: /* @__PURE__ */ jsx(EmitterCtx.Provider, {
461
+ children: /* @__PURE__ */ jsx(EventsCtx.Provider, {
456
462
  value: emitter,
457
463
  children
458
464
  })
@@ -1 +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/reconnect.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 * Provider 連線意圖與重連策略階段(`WsState` 的一環)。\n *\n * 與 `status`(WebSocket readyState 映射)正交,補足 UI 無法單靠 `status` 判斷的情境。\n *\n * - `idle` — 未連線、未排程重連(初始或手動 `disconnect()`)\n * - `connecting` — 首次或手動 `connect()` 連線中\n * - `open` — 已連線\n * - `reconnecting` — 自動重連週期(等待計時器或連線中);細節搭配 `status`、`reconnectAttempt`\n * - `stopped` — 不會再自動重連;`reconnectExhausted` 區分達上限或未啟用重連\n */\nexport type WsPhase =\n | \"idle\"\n | \"connecting\"\n | \"open\"\n | \"reconnecting\"\n | \"stopped\";\n\n/**\n * 可訂閱的連線層 state(低頻更新)。\n *\n * 只放:**連線健康**、**outbound 佇列**、**重連** 等連線生命週期資訊。\n *\n * 不放:訊息 payload、訊息歷史、業務資料(請用 `useWsEvents` 或自行管理 state)。\n *\n * 未來可能擴充例如 `pendingCount`;新增欄位時請維持低頻、可 selector 訂閱。\n */\nexport type WsState = {\n /** 連線生命週期狀態 */\n status: WsStatus;\n /** Provider 連線意圖與重連策略階段 */\n phase: WsPhase;\n /**\n * 本輪已排程的自動重連次數(意外斷線當下 +1,非重連成功才 +1)。\n *\n * 顯示為 `n` 時,代表第 `n` 次重連已排程或進行中。\n *\n * 成功 `open`、手動 `connect()` 或主動 `disconnect()` 歸零。\n */\n reconnectAttempt: number;\n /**\n * 本輪自動重連已達 `reconnectMax` 且最後一次也失敗。\n *\n * 手動 `connect()` 或 `disconnect()` 設定為 `false`\n */\n reconnectExhausted: boolean;\n};\n\nexport type WsStoreApi = StoreApi<WsState>;\n\nexport function createWsStore(init: WsStatus = \"idle\"): WsStoreApi {\n const phase: WsPhase = init === \"open\" ? \"open\" : init === \"idle\" ? \"idle\" : \"connecting\";\n return createStore<WsState>({\n status: init,\n phase,\n reconnectAttempt: 0,\n reconnectExhausted: false,\n });\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 { useState } from \"react\";\n\nexport interface ReconnectCallbacks {\n /** 讀取 store 的 `reconnectAttempt` */\n getAttempt: () => number;\n /** 寫入 store 的 `reconnectAttempt` */\n setAttempt: (attempt: number) => void;\n /** 寫入 store 的 `reconnectExhausted` */\n setExhausted: (exhausted: boolean) => void;\n}\n\nexport interface Reconnect {\n /** 開始連線時呼叫;回傳 `true` 表示由重連計時器觸發 */\n onConnectBegin: () => boolean;\n /** 連線成功時呼叫 */\n onOpen: () => void;\n /** 意外斷線後嘗試重連;有排重連回 `true` */\n scheduleAfterClose: () => boolean;\n /** 主動斷線或元件卸載時呼叫 */\n cancel: () => void;\n /** 設定重連時要執行的 connect */\n bindOnReconnect: (fn: () => void) => void;\n}\n\nexport function createReconnect(\n reconnectMs: number,\n reconnectMax: number,\n callbacks: ReconnectCallbacks,\n): Reconnect {\n let intentionalClose = false;\n let fromTimer = false;\n let timer: ReturnType<typeof setTimeout> | null = null;\n let onReconnect = () => {};\n\n const clearTimer = () => {\n if (timer != null) {\n clearTimeout(timer);\n timer = null;\n }\n };\n\n const resetCycle = () => {\n if (callbacks.getAttempt() !== 0) callbacks.setAttempt(0);\n callbacks.setExhausted(false);\n };\n\n return {\n onConnectBegin() {\n clearTimer();\n intentionalClose = false;\n const reconnecting = fromTimer;\n if (!fromTimer) resetCycle();\n fromTimer = false;\n return reconnecting;\n },\n\n onOpen() {\n resetCycle();\n },\n\n scheduleAfterClose() {\n if (intentionalClose || reconnectMs <= 0) return false;\n const attempt = callbacks.getAttempt();\n if (reconnectMax > 0 && attempt >= reconnectMax) {\n callbacks.setExhausted(true);\n return false;\n }\n callbacks.setAttempt(attempt + 1);\n fromTimer = true;\n // 固定間隔重連,無 backoff;之後可換成指數退避\n timer = setTimeout(() => {\n timer = null;\n onReconnect();\n }, reconnectMs);\n return true;\n },\n\n cancel() {\n intentionalClose = true;\n clearTimer();\n resetCycle();\n },\n\n bindOnReconnect(fn) {\n onReconnect = fn;\n },\n };\n}\n\nexport function useReconnect(\n reconnectMs: number,\n reconnectMax: number,\n callbacks: ReconnectCallbacks,\n): Reconnect {\n const [session] = useState(() =>\n createReconnect(reconnectMs, reconnectMax, callbacks),\n );\n return session;\n}\n","export 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 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 WsPhase,\n type WsStatus,\n} from \"./ws-store\";\nimport { createUseWsActions, createWsActionsContext } from \"./ws-actions\";\nimport { useReconnect } from \"./reconnect\";\nimport { clientCloseEvent, detachAndClose } 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 reconnectMax = 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 reconnect = useReconnect(reconnectMs, reconnectMax, {\n getAttempt: () => store.getState().reconnectAttempt,\n setAttempt: (reconnectAttempt) => store.setState({ reconnectAttempt }),\n setExhausted: (reconnectExhausted) =>\n store.setState({ reconnectExhausted }),\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 setPhase = useCallback(\n (phase: WsPhase) => {\n store.setState({ phase });\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 reconnect.cancel();\n livenessSession.stop();\n outgoingQueue.clear();\n setPhase(\"idle\");\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, setPhase, emitter, outgoingQueue, livenessSession, reconnect]);\n\n const connect = useCallback<WsContextValue[\"connect\"]>(() => {\n if (typeof window === \"undefined\") return;\n\n const fromReconnect = reconnect.onConnectBegin();\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 setPhase(fromReconnect ? \"reconnecting\" : \"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 reconnect.onOpen();\n setStatus(\"open\");\n setPhase(\"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 const scheduled = reconnect.scheduleAfterClose();\n if (scheduled) {\n setPhase(\"reconnecting\");\n } else if (store.getState().phase !== \"idle\") {\n setPhase(\"stopped\");\n }\n };\n }, [setStatus, setPhase, emitter, outgoingQueue, livenessSession, reconnect, store]);\n\n reconnect.bindOnReconnect(connect);\n\n useEffect(() => {\n if (autoConnect) connect();\n return () => {\n reconnect.cancel();\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, reconnect]);\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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"}
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/reconnect.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 依賴。\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","import {\n createContext,\n useContext,\n useEffect,\n useRef,\n useState,\n type Context,\n} from \"react\";\nimport { createEmitter, type Emitter } from \"./emitter\";\nimport type { WsEvents } from \"./types\";\n\nexport type WsEventsEmitter = Emitter<WsEvents>;\n\nexport function createWsEventsContext() {\n return createContext<WsEventsEmitter | null>(null);\n}\n\n/** 每個 `WsProvider` 各有一份 event emitter */\nexport function useWsEventsApi(): WsEventsEmitter {\n const [emitter] = useState(() => createEmitter<WsEvents>());\n return emitter;\n}\n\nexport function createUseWsEvents(EventsCtx: Context<WsEventsEmitter | null>) {\n function useWsEvents<E extends keyof WsEvents>(\n type: E,\n handler: WsEvents[E],\n ): void {\n const emitter = useContext(EventsCtx);\n if (!emitter) {\n throw new Error(\"useWsEvents 必須包在對應的 WsProvider 內\");\n }\n\n const handlerRef = useRef(handler);\n\n useEffect(() => {\n handlerRef.current = handler;\n });\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 /** 開始探活(socket 由 {@link createLiveness} 的 `getActiveSocket` 取得) */\n start: () => 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() {\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 const current = getActiveSocket();\n if (!current || current.readyState !== WebSocket.OPEN) return false;\n current.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 (!hasPartialChanged(state, nextPartial)) 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\nfunction hasPartialChanged<State extends object>(\n state: State,\n partial: Partial<State>,\n): boolean {\n for (const [key, value] of Object.entries(partial)) {\n if (!Object.is(state[key as keyof State], value)) {\n return true;\n }\n }\n return false;\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 * Provider 連線意圖與重連策略階段(`WsState` 的一環)。\n *\n * 與 `status`(WebSocket readyState 映射)正交,補足 UI 無法單靠 `status` 判斷的情境。\n *\n * - `idle` — 未連線、未排程重連(初始或手動 `disconnect()`)\n * - `connecting` — 首次或手動 `connect()` 連線中\n * - `open` — 已連線\n * - `reconnecting` — 自動重連週期(等待計時器或連線中);細節搭配 `status`、`reconnectAttempt`\n * - `stopped` — 不會再自動重連;`reconnectExhausted` 區分達上限或未啟用重連\n */\nexport type WsPhase =\n \"idle\" | \"connecting\" | \"open\" | \"reconnecting\" | \"stopped\";\n\n/**\n * 可訂閱的連線層 state(低頻更新)。\n *\n * 只放:**連線健康**、**outbound 佇列**、**重連** 等連線生命週期資訊。\n *\n * 不放:訊息 payload、訊息歷史、業務資料(請用 `useWsEvents` 或自行管理 state)。\n *\n * 未來可能擴充例如 `pendingCount`;新增欄位時請維持低頻、可 selector 訂閱。\n */\nexport type WsState = {\n /** 連線生命週期狀態 */\n status: WsStatus;\n /** Provider 連線意圖與重連策略階段 */\n phase: WsPhase;\n /**\n * 本輪已排程的自動重連次數(意外斷線當下 +1,非重連成功才 +1)。\n *\n * 顯示為 `n` 時,代表第 `n` 次重連已排程或進行中。\n *\n * 成功 `open`、手動 `connect()` 或主動 `disconnect()` 歸零。\n */\n reconnectAttempt: number;\n /**\n * 本輪自動重連已達 `reconnectMax` 且最後一次也失敗。\n *\n * 手動 `connect()` 或 `disconnect()` 設定為 `false`\n */\n reconnectExhausted: boolean;\n};\n\nexport type WsStoreApi = StoreApi<WsState>;\n\nexport function createWsStore(init: WsStatus = \"idle\"): WsStoreApi {\n const phase: WsPhase =\n init === \"open\" || init === \"idle\" ? init : \"connecting\";\n return createStore<WsState>({\n status: init,\n phase,\n reconnectAttempt: 0,\n reconnectExhausted: false,\n });\n}\n\nexport function createWsStoreContext() {\n return createContext<WsStoreApi | null>(null);\n}\n\n/** 每個 `WsProvider` 各有一份 {@link WsState} store */\nexport function useWsStoreApi(): WsStoreApi {\n const [store] = useState(() => createWsStore());\n return store;\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\n/** actions 無獨立 instance;由 WsProvider 以 useMemo 組裝後注入 Context */\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 { useState } from \"react\";\n\nexport interface ReconnectCallbacks {\n /** 讀取 store 的 `reconnectAttempt` */\n getAttempt: () => number;\n /** 寫入 store 的 `reconnectAttempt` */\n setAttempt: (attempt: number) => void;\n /** 寫入 store 的 `reconnectExhausted` */\n setExhausted: (exhausted: boolean) => void;\n}\n\nexport interface Reconnect {\n /** 開始連線時呼叫;回傳 `true` 表示由重連計時器觸發 */\n onConnectBegin: () => boolean;\n /** 連線成功時呼叫 */\n onOpen: () => void;\n /** 意外斷線後嘗試重連;有排重連回 `true` */\n scheduleAfterClose: () => boolean;\n /** 主動斷線或元件卸載時呼叫 */\n cancel: () => void;\n /** 設定重連時要執行的 connect */\n bindOnReconnect: (fn: () => void) => void;\n}\n\nexport function createReconnect(\n reconnectMs: number,\n reconnectMax: number,\n callbacks: ReconnectCallbacks,\n): Reconnect {\n let intentionalClose = false;\n let fromTimer = false;\n let timer: ReturnType<typeof setTimeout> | null = null;\n let onReconnect = () => {};\n\n const clearTimer = () => {\n if (timer != null) {\n clearTimeout(timer);\n timer = null;\n }\n };\n\n const resetCycle = () => {\n if (callbacks.getAttempt() !== 0) callbacks.setAttempt(0);\n callbacks.setExhausted(false);\n };\n\n return {\n onConnectBegin() {\n clearTimer();\n intentionalClose = false;\n const reconnecting = fromTimer;\n if (!fromTimer) resetCycle();\n fromTimer = false;\n return reconnecting;\n },\n\n onOpen() {\n resetCycle();\n },\n\n scheduleAfterClose() {\n if (intentionalClose || reconnectMs <= 0) return false;\n const attempt = callbacks.getAttempt();\n if (reconnectMax > 0 && attempt >= reconnectMax) {\n callbacks.setExhausted(true);\n return false;\n }\n callbacks.setAttempt(attempt + 1);\n fromTimer = true;\n // 固定間隔重連,無 backoff;之後可換成指數退避\n timer = setTimeout(() => {\n timer = null;\n onReconnect();\n }, reconnectMs);\n return true;\n },\n\n cancel() {\n intentionalClose = true;\n clearTimer();\n resetCycle();\n },\n\n bindOnReconnect(fn) {\n onReconnect = fn;\n },\n };\n}\n\nexport function useReconnect(\n reconnectMs: number,\n reconnectMax: number,\n callbacks: ReconnectCallbacks,\n): Reconnect {\n const [session] = useState(() =>\n createReconnect(reconnectMs, reconnectMax, callbacks),\n );\n return session;\n}\n","export 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 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 {\n createUseWsEvents,\n createWsEventsContext,\n useWsEventsApi,\n} from \"./ws-events\";\nimport type { CreateWsContextOptions, WsContextValue } from \"./types\";\nimport { useLiveness } from \"./liveness/liveness\";\nimport { useOutgoingQueue } from \"./outgoing-queue\";\nimport {\n createUseWsStore,\n createWsStoreContext,\n useWsStoreApi,\n type WsPhase,\n} from \"./ws-store\";\nimport { createUseWsActions, createWsActionsContext } from \"./ws-actions\";\nimport { useReconnect } from \"./reconnect\";\nimport { clientCloseEvent, detachAndClose } from \"./socket\";\n\nexport type { CreateWsContextOptions, WsContextValue, WsEvents } from \"./types\";\nexport type { LivenessOptions } from \"./liveness/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 reconnectMax = 0,\n outgoingQueueMax = 0,\n parse = defaultParse,\n liveness,\n } = options;\n\n const StoreCtx = createWsStoreContext();\n const useWsStore = createUseWsStore(StoreCtx);\n const ActionsCtx = createWsActionsContext();\n const useWsActions = createUseWsActions(ActionsCtx);\n const EventsCtx = createWsEventsContext();\n const useWsEvents = createUseWsEvents(EventsCtx);\n\n function WsProvider({ children }: PropsWithChildren) {\n const wsRef = useRef<WebSocket | null>(null);\n const store = useWsStoreApi();\n const emitter = useWsEventsApi();\n const reconnect = useReconnect(reconnectMs, reconnectMax, {\n getAttempt: () => store.getState().reconnectAttempt,\n setAttempt: (reconnectAttempt) => store.setState({ reconnectAttempt }),\n setExhausted: (reconnectExhausted) =>\n store.setState({ reconnectExhausted }),\n });\n const outgoingQueue = useOutgoingQueue(outgoingQueueMax);\n const livenessSession = useLiveness(liveness, () => wsRef.current);\n\n const getStatus = useCallback<WsContextValue[\"getStatus\"]>(\n () => store.getState().status,\n [store],\n );\n\n /**\n * 主動斷線與 Provider unmount 共用 cleanup。\n *\n * 關閉 socket 時以 `reason` 寫入 synthetic `close` 事件:\n * - `\"client disconnect\"` — `disconnect()`\n * - `\"provider unmount\"` — `WsProvider` unmount\n */\n const teardown = useCallback(\n (reason: string) => {\n reconnect.cancel();\n livenessSession.stop();\n outgoingQueue.clear();\n store.setState({ phase: \"idle\", status: \"closed\" });\n const ws = wsRef.current;\n wsRef.current = null;\n if (ws) {\n detachAndClose(ws);\n emitter.emit(\"close\", clientCloseEvent(reason));\n }\n },\n [store, emitter, outgoingQueue, livenessSession, reconnect],\n );\n\n const disconnect = useCallback<WsContextValue[\"disconnect\"]>(\n () => teardown(\"client disconnect\"),\n [teardown],\n );\n\n const connect = useCallback<WsContextValue[\"connect\"]>(() => {\n if (typeof window === \"undefined\") return;\n\n const fromReconnect = reconnect.onConnectBegin();\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 store.setState({\n status: \"connecting\",\n phase: fromReconnect ? \"reconnecting\" : \"connecting\",\n });\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 reconnect.onOpen();\n store.setState({ status: \"open\", phase: \"open\" });\n outgoingQueue.flush((data) => ws.send(data));\n livenessSession.start();\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 const scheduled = reconnect.scheduleAfterClose();\n // 意外斷線:先更新 store,再 emit close(handler 可讀到一致的 status / phase)\n const patch: { status: \"closed\"; phase?: WsPhase } = {\n status: \"closed\",\n };\n if (scheduled) {\n patch.phase = \"reconnecting\";\n } else if (store.getState().phase !== \"idle\") {\n patch.phase = \"stopped\";\n }\n store.setState(patch);\n emitter.emit(\"close\", event);\n };\n }, [store, emitter, outgoingQueue, livenessSession, reconnect]);\n\n reconnect.bindOnReconnect(connect);\n\n useEffect(() => {\n if (autoConnect) connect();\n return () => teardown(\"provider unmount\");\n }, [connect, teardown]);\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) => {\n try {\n return send(JSON.stringify(data));\n } catch {\n return false;\n }\n },\n [send],\n );\n\n const actions = useMemo<WsContextValue>(\n () => ({ send, sendJson, connect, disconnect, getStatus }),\n [send, sendJson, connect, disconnect, getStatus],\n );\n\n return (\n <ActionsCtx.Provider value={actions}>\n <StoreCtx.Provider value={store}>\n <EventsCtx.Provider value={emitter}>{children}</EventsCtx.Provider>\n </StoreCtx.Provider>\n </ActionsCtx.Provider>\n );\n }\n\n return {\n WsProvider,\n useWsActions,\n useWsStore,\n useWsEvents,\n };\n}\n"],"mappings":";;;;AAkBA,SAAgB,gBAEK;CACnB,OAAO;EACL,QAAQ,CAAC;EACT,KAAK,OAAO,GAAG,MAAM;GACnB,MAAM,YAAY,KAAK,OAAO,UAAU,CAAC;GACzC,KAAK,IAAI,IAAI,GAAG,MAAM,UAAU,QAAQ,IAAI,KAAK,KAC/C,UAAU,EAAE,CAAE,GAAG,IAAI;EAEzB;EACA,GAAG,OAAO,IAAI;GACZ,CAAC,KAAK,OAAO,WAAW,CAAC,EAAA,CAAG,KAAK,EAAE;GACnC,aAAa;IACX,KAAK,OAAO,SAAS,KAAK,OAAO,MAAM,EAAE,QAAQ,OAAO,OAAO,EAAE;GACnE;EACF;CACF;AACF;;;ACvBA,SAAgB,wBAAwB;CACtC,OAAO,cAAsC,IAAI;AACnD;;AAGA,SAAgB,iBAAkC;CAChD,MAAM,CAAC,WAAW,eAAe,cAAwB,CAAC;CAC1D,OAAO;AACT;AAEA,SAAgB,kBAAkB,WAA4C;CAC5E,SAAS,YACP,MACA,SACM;EACN,MAAM,UAAU,WAAW,SAAS;EACpC,IAAI,CAAC,SACH,MAAM,IAAI,MAAM,kCAAkC;EAGpD,MAAM,aAAa,OAAO,OAAO;EAEjC,gBAAgB;GACd,WAAW,UAAU;EACvB,CAAC;EAED,gBAAgB;GACd,OAAO,QAAQ,GAAG,QAAQ,GAAG,SAAkB;IAC7C,WAAY,QAAoC,GAAG,IAAI;GACzD,EAAiB;EACnB,GAAG,CAAC,MAAM,OAAO,CAAC;CACpB;CAEA,OAAO;AACT;;;AC7CA,SAAgB,mBAAmB,MAAwC;CACzE,OAAO,OAAO,SAAS,aAAa,KAAK,IAAI;AAC/C;;;ACQA,SAAgB,yBACd,SACA,WACoB;CACpB,MAAM,EAAE,YAAY,WAAW,WAAW;CAE1C,IAAI,aAAoD;CACxD,IAAI,YAAkD;CACtD,IAAI,cAAmC;CAEvC,SAAS,oBAA0B;EACjC,IAAI,aAAa,MAAM;GACrB,aAAa,SAAS;GACtB,YAAY;EACd;CACF;CAEA,SAAS,aAAmB;EAC1B,kBAAkB;EAClB,YAAY,WAAW,WAAW,SAAS;CAC7C;CAEA,SAAS,OAAa;EACpB,cAAc;EACd,WAAW;CACb;CAEA,OAAO;EACL,MAAM,UAAU;GACd,cAAc;GACd,KAAK;GACL,aAAa,YAAY,MAAM,UAAU;EAC3C;EAEA,OAAO;GACL,IAAI,cAAc,MAAM;IACtB,cAAc,UAAU;IACxB,aAAa;GACf;GACA,kBAAkB;GAClB,cAAc;EAChB;EAEA,UAAU,MAAM;GACd,IAAI,OAAO,IAAI,GAAG,kBAAkB;EACtC;CACF;AACF;AAEA,SAAgB,iBACd,MACA,UACY;CACZ,aAAa;EACX,SAAS,mBAAmB,IAAI,CAAC;CACnC;AACF;;;ACnDA,MAAM,oBAA8B;CAClC,QAAQ,CAAC;CACT,OAAO,CAAC;CACR,YAAY,CAAC;AACf;AAEA,SAAgB,eACd,SACA,iBACU;CACV,IAAI,aAAwC;CAE5C,OAAO;EACL,QAAQ;GACN,YAAY,KAAK;GACjB,aAAa,yBAAyB,eAAe;IACnD,MAAM,UAAU,gBAAgB;IAChC,IAAI,SAAS,eAAe,UAAU,MAAM,QAAQ,MAAM;GAC5D,CAAC;GACD,MAAM,WAAW,iBAAiB,QAAQ,OAAO,SAAS;IACxD,MAAM,UAAU,gBAAgB;IAChC,IAAI,CAAC,WAAW,QAAQ,eAAe,UAAU,MAAM,OAAO;IAC9D,QAAQ,KAAK,KAAK,UAAU,IAAI,CAAC;IACjC,OAAO;GACT,CAAC;GACD,WAAW,MAAM,QAAQ;EAC3B;EAEA,OAAO;GACL,YAAY,KAAK;GACjB,aAAa;EACf;EAEA,UAAU,MAAM;GACd,YAAY,UAAU,IAAI;EAC5B;CACF;AACF;AAEA,SAAgB,YACd,SACA,iBACU;CACV,MAAM,CAAC,WAAW,eAChB,UAAU,eAAe,SAAS,eAAe,IAAI,iBACvD;CACA,OAAO;AACT;;;ACnDA,SAAgB,oBAAoB,KAA4B;CAC9D,IAAI,QAAwB,CAAC;CAE7B,OAAO;EACL,QAAQ,MAAM;GACZ,IAAI,OAAO,KAAK,MAAM,UAAU,KAAK,OAAO;GAC5C,MAAM,KAAK,IAAI;GACf,OAAO;EACT;EACA,QAAQ;GACN,QAAQ,CAAC;EACX;EACA,MAAM,MAAM;GACV,MAAM,SAAS;GACf,QAAQ,CAAC;GACT,KAAK,MAAM,QAAQ,QAAQ,KAAK,IAAI;EACtC;CACF;AACF;AAEA,SAAgB,iBAAiB,KAA4B;CAC3D,MAAM,CAAC,SAAS,eAAe,oBAAoB,GAAG,CAAC;CACvD,OAAO;AACT;;;ACnBA,SAAgB,YACd,cACiB;CACjB,IAAI,QAAQ;CACZ,MAAM,4BAAY,IAAI,IAAyC;CAE/D,OAAO;EACL,gBAAgB;EAChB,uBAAuB;EACvB,WAAW,YAAY;GACrB,MAAM,cACJ,OAAO,YAAY,aAAa,QAAQ,KAAK,IAAI;GACnD,IAAI,CAAC,kBAAkB,OAAO,WAAW,GAAG;GAC5C,MAAM,OAAO;GACb,QAAQ,OAAO,OAAO,CAAC,GAAG,OAAO,WAAW;GAC5C,KAAK,MAAM,YAAY,WAAW,SAAS,OAAO,IAAI;EACxD;EACA,YAAY,aAAa;GACvB,UAAU,IAAI,QAAQ;GACtB,aAAa;IACX,UAAU,OAAO,QAAQ;GAC3B;EACF;CACF;AACF;AAEA,SAAS,kBACP,OACA,SACS;CACT,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,OAAO,GAC/C,IAAI,CAAC,OAAO,GAAG,MAAM,MAAqB,KAAK,GAC7C,OAAO;CAGX,OAAO;AACT;;;ACzCA,SAAgB,SACd,OACA,UACU;CACV,OAAO,qBACL,MAAM,iBACA,SAAS,MAAM,SAAS,CAAC,SACzB,SAAS,MAAM,gBAAgB,CAAC,CACxC;AACF;;;ACyCA,SAAgB,cAAc,OAAiB,QAAoB;CAGjE,OAAO,YAAqB;EAC1B,QAAQ;EACR,OAHA,SAAS,UAAU,SAAS,SAAS,OAAO;EAI5C,kBAAkB;EAClB,oBAAoB;CACtB,CAAC;AACH;AAEA,SAAgB,uBAAuB;CACrC,OAAO,cAAiC,IAAI;AAC9C;;AAGA,SAAgB,gBAA4B;CAC1C,MAAM,CAAC,SAAS,eAAe,cAAc,CAAC;CAC9C,OAAO;AACT;;AAGA,SAAgB,iBAAiB,UAAsC;CAGrE,SAAS,WAAc,UAAqC;EAC1D,MAAM,QAAQ,WAAW,QAAQ;EACjC,IAAI,CAAC,OACH,MAAM,IAAI,MAAM,iCAAiC;EAGnD,OAAO,SAAS,OADD,cAAc,UAAmB,MACnB;CAC/B;CAEA,OAAO;AACT;;;;AC7FA,SAAgB,yBAAyB;CACvC,OAAO,cAAqC,IAAI;AAClD;AAEA,SAAgB,mBAAmB,YAA4C;CAC7E,SAAS,eAA+B;EACtC,MAAM,QAAQ,WAAW,UAAU;EACnC,IAAI,CAAC,OACH,MAAM,IAAI,MAAM,mCAAmC;EAErD,OAAO;CACT;CAEA,OAAO;AACT;;;ACMA,SAAgB,gBACd,aACA,cACA,WACW;CACX,IAAI,mBAAmB;CACvB,IAAI,YAAY;CAChB,IAAI,QAA8C;CAClD,IAAI,oBAAoB,CAAC;CAEzB,MAAM,mBAAmB;EACvB,IAAI,SAAS,MAAM;GACjB,aAAa,KAAK;GAClB,QAAQ;EACV;CACF;CAEA,MAAM,mBAAmB;EACvB,IAAI,UAAU,WAAW,MAAM,GAAG,UAAU,WAAW,CAAC;EACxD,UAAU,aAAa,KAAK;CAC9B;CAEA,OAAO;EACL,iBAAiB;GACf,WAAW;GACX,mBAAmB;GACnB,MAAM,eAAe;GACrB,IAAI,CAAC,WAAW,WAAW;GAC3B,YAAY;GACZ,OAAO;EACT;EAEA,SAAS;GACP,WAAW;EACb;EAEA,qBAAqB;GACnB,IAAI,oBAAoB,eAAe,GAAG,OAAO;GACjD,MAAM,UAAU,UAAU,WAAW;GACrC,IAAI,eAAe,KAAK,WAAW,cAAc;IAC/C,UAAU,aAAa,IAAI;IAC3B,OAAO;GACT;GACA,UAAU,WAAW,UAAU,CAAC;GAChC,YAAY;GAEZ,QAAQ,iBAAiB;IACvB,QAAQ;IACR,YAAY;GACd,GAAG,WAAW;GACd,OAAO;EACT;EAEA,SAAS;GACP,mBAAmB;GACnB,WAAW;GACX,WAAW;EACb;EAEA,gBAAgB,IAAI;GAClB,cAAc;EAChB;CACF;AACF;AAEA,SAAgB,aACd,aACA,cACA,WACW;CACX,MAAM,CAAC,WAAW,eAChB,gBAAgB,aAAa,cAAc,SAAS,CACtD;CACA,OAAO;AACT;;;AClGA,SAAgB,eAAe,IAAqB;CAClD,GAAG,SAAS;CACZ,GAAG,YAAY;CACf,GAAG,UAAU;CACb,GAAG,UAAU;CACb,IAAI,GAAG,aAAa,UAAU,SAAS,GAAG,MAAM;AAClD;AAEA,SAAgB,iBAAiB,QAA4B;CAC3D,OAAO,IAAI,WAAW,SAAS;EAC7B,MAAM;EACN;EACA,UAAU;CACZ,CAAC;AACH;;;ACcA,SAAS,aAAa,MAAqC;CACzD,IAAI,OAAO,SAAS,UAAU,OAAO;CACrC,IAAI;EACF,OAAO,KAAK,MAAM,IAAI;CACxB,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;AASA,SAAgB,gBAAgB,SAAiC;CAC/D,MAAM,EACJ,KACA,WACA,cAAc,MACd,cAAc,GACd,eAAe,GACf,mBAAmB,GACnB,QAAQ,cACR,aACE;CAEJ,MAAM,WAAW,qBAAqB;CACtC,MAAM,aAAa,iBAAiB,QAAQ;CAC5C,MAAM,aAAa,uBAAuB;CAC1C,MAAM,eAAe,mBAAmB,UAAU;CAClD,MAAM,YAAY,sBAAsB;CACxC,MAAM,cAAc,kBAAkB,SAAS;CAE/C,SAAS,WAAW,EAAE,YAA+B;EACnD,MAAM,QAAQ,OAAyB,IAAI;EAC3C,MAAM,QAAQ,cAAc;EAC5B,MAAM,UAAU,eAAe;EAC/B,MAAM,YAAY,aAAa,aAAa,cAAc;GACxD,kBAAkB,MAAM,SAAS,CAAC,CAAC;GACnC,aAAa,qBAAqB,MAAM,SAAS,EAAE,iBAAiB,CAAC;GACrE,eAAe,uBACb,MAAM,SAAS,EAAE,mBAAmB,CAAC;EACzC,CAAC;EACD,MAAM,gBAAgB,iBAAiB,gBAAgB;EACvD,MAAM,kBAAkB,YAAY,gBAAgB,MAAM,OAAO;EAEjE,MAAM,YAAY,kBACV,MAAM,SAAS,CAAC,CAAC,QACvB,CAAC,KAAK,CACR;;;;;;;;EASA,MAAM,WAAW,aACd,WAAmB;GAClB,UAAU,OAAO;GACjB,gBAAgB,KAAK;GACrB,cAAc,MAAM;GACpB,MAAM,SAAS;IAAE,OAAO;IAAQ,QAAQ;GAAS,CAAC;GAClD,MAAM,KAAK,MAAM;GACjB,MAAM,UAAU;GAChB,IAAI,IAAI;IACN,eAAe,EAAE;IACjB,QAAQ,KAAK,SAAS,iBAAiB,MAAM,CAAC;GAChD;EACF,GACA;GAAC;GAAO;GAAS;GAAe;GAAiB;EAAS,CAC5D;EAEA,MAAM,aAAa,kBACX,SAAS,mBAAmB,GAClC,CAAC,QAAQ,CACX;EAEA,MAAM,UAAU,kBAA6C;GAC3D,IAAI,OAAO,WAAW,aAAa;GAEnC,MAAM,gBAAgB,UAAU,eAAe;GAC/C,gBAAgB,KAAK;GAErB,MAAM,OAAO,MAAM;GACnB,IAAI,MAAM;IACR,MAAM,UAAU;IAChB,eAAe,IAAI;IACnB,QAAQ,KAAK,SAAS,iBAAiB,WAAW,CAAC;GACrD;GAEA,MAAM,SAAS;IACb,QAAQ;IACR,OAAO,gBAAgB,iBAAiB;GAC1C,CAAC;GAED,MAAM,KAAK,YAAY,IAAI,UAAU,KAAK,SAAS,IAAI,IAAI,UAAU,GAAG;GACxE,MAAM,UAAU;GAEhB,GAAG,UAAU,UAAU;IACrB,IAAI,MAAM,YAAY,IAAI;IAC1B,UAAU,OAAO;IACjB,MAAM,SAAS;KAAE,QAAQ;KAAQ,OAAO;IAAO,CAAC;IAChD,cAAc,OAAO,SAAS,GAAG,KAAK,IAAI,CAAC;IAC3C,gBAAgB,MAAM;IACtB,QAAQ,KAAK,QAAQ,KAAK;GAC5B;GAEA,GAAG,aAAa,UAAU;IACxB,IAAI,MAAM,YAAY,IAAI;IAC1B,MAAM,OAAO,MAAM,MAAM,IAAI;IAC7B,gBAAgB,UAAU,IAAI;IAC9B,QAAQ,KAAK,WAAW,MAAM,KAAK;GACrC;GAEA,GAAG,WAAW,UAAU;IACtB,IAAI,MAAM,YAAY,IAAI;IAC1B,QAAQ,KAAK,SAAS,KAAK;GAC7B;GAEA,GAAG,WAAW,UAAU;IACtB,IAAI,MAAM,YAAY,IAAI,MAAM,UAAU;IAC1C,gBAAgB,KAAK;IACrB,MAAM,YAAY,UAAU,mBAAmB;IAE/C,MAAM,QAA+C,EACnD,QAAQ,SACV;IACA,IAAI,WACF,MAAM,QAAQ;SACT,IAAI,MAAM,SAAS,CAAC,CAAC,UAAU,QACpC,MAAM,QAAQ;IAEhB,MAAM,SAAS,KAAK;IACpB,QAAQ,KAAK,SAAS,KAAK;GAC7B;EACF,GAAG;GAAC;GAAO;GAAS;GAAe;GAAiB;EAAS,CAAC;EAE9D,UAAU,gBAAgB,OAAO;EAEjC,gBAAgB;GACd,IAAI,aAAa,QAAQ;GACzB,aAAa,SAAS,kBAAkB;EAC1C,GAAG,CAAC,SAAS,QAAQ,CAAC;EAEtB,MAAM,OAAO,aACV,SAAS;GACR,MAAM,KAAK,MAAM;GACjB,IAAI,MAAM,GAAG,eAAe,UAAU,MAAM;IAC1C,GAAG,KAAK,IAAI;IACZ,OAAO;GACT;GACA,OAAO,cAAc,QAAQ,IAAI;EACnC,GACA,CAAC,aAAa,CAChB;EAEA,MAAM,WAAW,aACd,SAAS;GACR,IAAI;IACF,OAAO,KAAK,KAAK,UAAU,IAAI,CAAC;GAClC,QAAQ;IACN,OAAO;GACT;EACF,GACA,CAAC,IAAI,CACP;EAEA,MAAM,UAAU,eACP;GAAE;GAAM;GAAU;GAAS;GAAY;EAAU,IACxD;GAAC;GAAM;GAAU;GAAS;GAAY;EAAS,CACjD;EAEA,OACE,oBAAC,WAAW,UAAZ;GAAqB,OAAO;GAC1B,UAAA,oBAAC,SAAS,UAAV;IAAmB,OAAO;IACxB,UAAA,oBAAC,UAAU,UAAX;KAAoB,OAAO;KAAU;IAA6B,CAAA;GACjD,CAAA;EACA,CAAA;CAEzB;CAEA,OAAO;EACL;EACA;EACA;EACA;CACF;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-ws-context",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "React WebSocket connection layer — separate hooks for actions, connection state, and message events to avoid unnecessary re-renders",
5
5
  "keywords": [
6
6
  "react",