react-ws-context 0.2.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-08-29
11
+
12
+ ### Fixed
13
+
14
+ - Store `setState` skips listener notification when partial values are unchanged (shallow compare on updated keys)
15
+ - Provider unmount syncs store to `status: "closed"` and `phase: "idle"` (consistent with `disconnect()`)
16
+ - Liveness ping and timeout both use `getActiveSocket()` for the active socket
17
+ - `sendJson` returns `false` when `JSON.stringify` fails (e.g. circular reference) instead of throwing
18
+ - `useWsEvents` handler ref sync moved into `useEffect` (complies with `react-hooks/refs`)
19
+
20
+ ### Changed
21
+
22
+ - Shared `teardown()` for `disconnect()` and provider unmount cleanup
23
+ - On unintentional close, store updates before `close` is emitted so handlers see consistent `status` / `phase`
24
+ - Batch `status` and `phase` store updates where both change together
25
+ - `Liveness.start()` takes no socket argument; uses `getActiveSocket()` at runtime
26
+ - Internal layout: `useWsEventsApi` in `ws-events.ts` (`emitter.ts` is React-free); aligned Context module structure (`ws-store`, `ws-events`, `ws-actions`)
27
+
28
+ ### Added
29
+
30
+ - Store smoke test for `setState` deduplication
31
+
32
+ ## [0.3.0] - 2026-08-29
33
+
34
+ ### Added
35
+
36
+ - `WsPhase` type — provider connection intent and reconnect strategy: `idle` | `connecting` | `open` | `reconnecting` | `stopped`
37
+ - `WsState.phase` — subscribable provider lifecycle phase, orthogonal to `status` (WebSocket readyState mapping); use with `reconnectAttempt` / `reconnectExhausted` for reconnect UI
38
+ - Exported `WsPhase` from the package entry
39
+
10
40
  ## [0.2.0] - 2026-08-28
11
41
 
12
42
  ### Added
package/README.md CHANGED
@@ -93,7 +93,8 @@ createWsContext(options)
93
93
  ```
94
94
 
95
95
  - **Call `createWsContext` multiple times** for independent connections (e.g. app WS + notification WS).
96
- - **`WsState` holds low-frequency connection data only** — health (`status`), outbound queue (e.g. future `pendingCount`), reconnect (`reconnectAttempt`). **Not** message payloads or app data.
96
+ - **Provider instances** — `useWsStoreApi` (store), `useWsEventsApi` (emitter); actions are assembled in `WsProvider` via `useMemo` and passed through Context.
97
+ - **`WsState` holds low-frequency connection data only** — health (`status`, `phase`), outbound queue (e.g. future `pendingCount`), reconnect (`reconnectAttempt`). **Not** message payloads or app data.
97
98
  - **Messages and errors** — use `useWsEvents`; keep message history in your own state, cache, or store.
98
99
  - **Connection errors are not a `WsStatus`** — use `useWsEvents("error")`; native `error` is usually followed by `close`.
99
100
 
@@ -141,7 +142,8 @@ Creates, owns, and tears down the native `WebSocket`.
141
142
  | Behavior | Description |
142
143
  | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
143
144
  | mount + `autoConnect: true` | Calls `connect()` |
144
- | unmount | Closes connection, stops liveness, clears outbound queue, emits `close` |
145
+ | unmount | Cancels reconnect, stops liveness, clears outbound queue; syncs store to `status: "closed"`, `phase: "idle"`; closes socket and emits `close` (reason: `"provider unmount"`) |
146
+ | `disconnect()` | Same store reset as unmount (`phase: "idle"`, `status: "closed"`), no auto-reconnect; emits `close` (reason: `"client disconnect"`) |
145
147
  | reconnect | Fixed interval when `reconnectMs > 0` and close was not intentional (no exponential backoff); stops after `reconnectMax` if `> 0` |
146
148
  | before reconnect | Closes existing socket and emits `close` (reason: `"reconnect"`) |
147
149
 
@@ -156,13 +158,13 @@ Must be used inside the matching `WsProvider`. Return value is memoized and **do
156
158
  | `send` | `(data) => boolean` | Send raw data. Sends immediately when OPEN; otherwise enqueues if configured |
157
159
  | `sendJson` | `(data: unknown) => boolean` | `JSON.stringify` then `send` |
158
160
  | `connect` | `() => void` | Open connection; closes any existing socket first |
159
- | `disconnect` | `() => void` | Intentional close; no auto-reconnect; clears outbound queue |
161
+ | `disconnect` | `() => void` | Intentional close; sets store to `phase: "idle"`, `status: "closed"`; no auto-reconnect; clears outbound queue |
160
162
  | `getStatus` | `() => WsStatus` | Read current status; no subscription, no re-render |
161
163
 
162
164
  **`send` / `sendJson` return value:**
163
165
 
164
166
  - `true` — sent or enqueued
165
- - `false` — not OPEN and queue full (`outgoingQueueMax > 0`), or queue disabled (`outgoingQueueMax === 0`)
167
+ - `false` — not OPEN and queue full (`outgoingQueueMax > 0`), queue disabled (`outgoingQueueMax === 0`), or `sendJson` failed to `JSON.stringify` (e.g. circular reference)
166
168
 
167
169
  ---
168
170
 
@@ -182,6 +184,8 @@ useWsStore<T>(selector: (state: WsState) => T): T
182
184
  ```ts
183
185
  interface WsState {
184
186
  status: WsStatus;
187
+ /** Provider connection intent and reconnect strategy; orthogonal to `status` */
188
+ phase: WsPhase;
185
189
  /** Reconnects scheduled this cycle (+1 on unintentional close, not on success) */
186
190
  reconnectAttempt: number;
187
191
  /** `true` when `reconnectMax` is hit and the final attempt failed; cleared by `connect()` / `disconnect()` */
@@ -191,9 +195,9 @@ interface WsState {
191
195
  }
192
196
  ```
193
197
 
194
- | Belongs in store | Does not belong |
195
- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
196
- | `status`, reconnect progress (`reconnectAttempt` / `reconnectExhausted`), pending queue size, liveness / stall summaries | `lastMessage`, message history, app payloads |
198
+ | Belongs in store | Does not belong |
199
+ | ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
200
+ | `status`, `phase`, reconnect progress (`reconnectAttempt` / `reconnectExhausted`), pending queue size, liveness / stall summaries | `lastMessage`, message history, app payloads |
197
201
 
198
202
  `CreateWsContextOptions` (e.g. `url`, `reconnectMax`) are frozen at `createWsContext` and are **not** in `WsState`. For UI like `n/max`, keep the config alongside the store fields you subscribe to.
199
203
 
@@ -206,10 +210,31 @@ interface WsState {
206
210
  | `open` | Connected |
207
211
  | `closed` | Disconnected |
208
212
 
213
+ Maps to the current WebSocket connection state (similar to readyState). Does **not** express provider intent such as “in an auto-reconnect cycle” or “user disconnected” — use `phase` for that.
214
+
215
+ #### `WsPhase`
216
+
217
+ | Value | Meaning |
218
+ | --------------- | ------------------------------------------------------------------------------------------------ |
219
+ | `idle` | Not connected, no reconnect scheduled (initial or manual `disconnect()`) |
220
+ | `connecting` | First connect or manual `connect()` in progress |
221
+ | `open` | Connected |
222
+ | `reconnecting` | Auto-reconnect cycle (waiting for timer or connecting); pair with `status`, `reconnectAttempt` |
223
+ | `stopped` | Will not auto-reconnect; use `reconnectExhausted` to distinguish max retries vs reconnect disabled |
224
+
225
+ `status` and `phase` often change together but mean different things. For example, `phase === "reconnecting"` with `status === "closed"` means waiting for the reconnect timer; `status === "connecting"` means the timer fired and a connect attempt is in progress.
226
+
209
227
  **Tip:** use a selector to subscribe to only the fields you need.
210
228
 
211
229
  ```tsx
230
+ const phase = useWsStore((s) => s.phase);
212
231
  const status = useWsStore((s) => s.status);
232
+
233
+ // Manual connect: only when idle or stopped
234
+ const canConnect = phase === "idle" || phase === "stopped";
235
+ // Intentional disconnect: while connected or in a connect/reconnect attempt
236
+ const canDisconnect =
237
+ phase === "open" || phase === "connecting" || phase === "reconnecting";
213
238
  ```
214
239
 
215
240
  ---
@@ -229,6 +254,7 @@ Must be used inside the matching `WsProvider`. Registers in `useEffect` and unsu
229
254
 
230
255
  - Handler is kept in a ref — changing the callback does **not** re-subscribe
231
256
  - Changing `type` **does** re-subscribe
257
+ - On unintentional close, the store is updated to `status: "closed"` and the appropriate `phase` before the `close` handler runs
232
258
  - For multiple events, call `useWsEvents` multiple times
233
259
 
234
260
  ---
@@ -292,7 +318,8 @@ From the main `react-ws-context` entry:
292
318
  | `CreateWsContextOptions` | Options for `createWsContext` |
293
319
  | `WsContextValue` | Return type of `useWsActions()` |
294
320
  | `WsEvents` | Event name → handler map |
295
- | `WsStatus` | Connection lifecycle status |
321
+ | `WsStatus` | WebSocket connection state (`WsState`) |
322
+ | `WsPhase` | Provider connection intent / reconnect phase (`WsState`) |
296
323
  | `WsState` | Subscribable store shape (health / queue / reconnect) |
297
324
 
298
325
  ---
@@ -330,7 +357,7 @@ import {
330
357
  | Topic | Notes |
331
358
  | ---------------- | ------------------------------------------------------------------------------ |
332
359
  | Immutable config | `url`, `reconnectMs`, etc. are fixed at create time |
333
- | Reconnect | Fixed interval only; no exponential backoff or max retries yet |
360
+ | Reconnect | Fixed interval only; no exponential backoff; optional cap via `reconnectMax` |
334
361
  | SSR | No `WebSocket` on the server; `connect()` is a no-op without `window` |
335
362
  | Error status | No `"error"` in `WsStatus`; use `useWsEvents("error")` |
336
363
  | `WsState` scope | Health / queue / reconnect only — not messages or app data |
@@ -361,5 +388,5 @@ This package does **not** list zustand or nanoevents as npm dependencies. It inl
361
388
 
362
389
  - **Author:** [Andrey Sitnik](https://github.com/ai) (`ai`)
363
390
  - **License:** [MIT](https://github.com/ai/nanoevents/blob/main/LICENSE)
364
- - **Adapted from:** [`createNanoEvents`](https://github.com/ai/nanoevents/blob/main/index.js); `useEmitter` added by this package
365
- - **File:** `src/ws-context/emitter.ts`
391
+ - **Adapted from:** [`createNanoEvents`](https://github.com/ai/nanoevents/blob/main/index.js); `useWsEventsApi` added by this package (`ws-events.ts`)
392
+ - **Files:** `src/ws-context/emitter.ts`, `src/ws-context/ws-events.ts`
package/README.zh-TW.md CHANGED
@@ -93,7 +93,8 @@ createWsContext(options)
93
93
  ```
94
94
 
95
95
  - **同一應用可多次呼叫 `createWsContext`**,每次產生一組互不共用的 Provider 與 hooks(例如同時連業務 WS 與通知 WS)。
96
- - **`WsState` 只放連線層、低頻欄位**連線健康(如 `status`)、outbound 佇列(如未來 `pendingCount`)、重連(`reconnectAttempt`)。**不放**訊息 payload 或業務資料。
96
+ - **Provider 內部 instance** `useWsStoreApi`(store)、`useWsEventsApi`(emitter);actions `WsProvider` 內以 `useMemo` 組裝後注入 Context。
97
+ - **`WsState` 只放連線層、低頻欄位** — 連線健康(`status`、`phase`)、outbound 佇列(如未來 `pendingCount`)、重連(`reconnectAttempt`)。**不放**訊息 payload 或業務資料。
97
98
  - **訊息與錯誤事件** — 請用 `useWsEvents`;訊息歷史請自行寫入 state、cache 或外部 store。
98
99
  - **連線錯誤不反映在 `WsStatus`** — 請用 `useWsEvents("error", …)` 處理;原生 `error` 事件後通常緊接 `close`。
99
100
 
@@ -141,7 +142,8 @@ createWsContext(options)
141
142
  | 行為 | 說明 |
142
143
  | --------------------------- | ---------------------------------------------------------------------------------------------------------------- |
143
144
  | mount + `autoConnect: true` | 自動 `connect()` |
144
- | unmount | 主動關閉連線、停止探活、清空 outbound 佇列,並 emit `close` |
145
+ | unmount | 取消重連、停止探活、清空 outbound 佇列;store 同步為 `status: "closed"`、`phase: "idle"`;關閉 socket 並 emit `close`(reason: `"provider unmount"`) |
146
+ | `disconnect()` | 同 unmount 的 store 重置(`phase: "idle"`、`status: "closed"`),但不觸發自動重連;emit `close`(reason: `"client disconnect"`) |
145
147
  | 重連 | 非主動斷線且 `reconnectMs > 0` 時,以固定間隔重試(無 exponential backoff);`reconnectMax > 0` 時超過次數即停止 |
146
148
  | 重連前 | 若已有舊 socket,先關閉並 emit `close`(reason: `"reconnect"`) |
147
149
 
@@ -156,13 +158,13 @@ createWsContext(options)
156
158
  | `send` | `(data) => boolean` | 傳送原始資料(`string`、`ArrayBuffer`、`Blob` 等)。已 OPEN 則立即送出;否則視佇列設定入隊 |
157
159
  | `sendJson` | `(data: unknown) => boolean` | `JSON.stringify` 後呼叫 `send` |
158
160
  | `connect` | `() => void` | 建立連線;若已有連線會先關閉舊 socket |
159
- | `disconnect` | `() => void` | 主動斷線,**不**觸發自動重連;清空 outbound 佇列 |
161
+ | `disconnect` | `() => void` | 主動斷線;store 設為 `phase: "idle"`、`status: "closed"`,**不**觸發自動重連;清空 outbound 佇列 |
160
162
  | `getStatus` | `() => WsStatus` | 讀取當下 status;不訂閱、不觸發渲染 |
161
163
 
162
164
  **`send` / `sendJson` 回傳值:**
163
165
 
164
166
  - `true` — 已送出,或已成功入隊
165
- - `false` — 未 OPEN 且佇列已滿(`outgoingQueueMax > 0` 且達上限),或佇列關閉(`outgoingQueueMax === 0`)
167
+ - `false` — 未 OPEN 且佇列已滿(`outgoingQueueMax > 0` 且達上限),或佇列關閉(`outgoingQueueMax === 0`);`sendJson` 另含 `JSON.stringify` 失敗(如 circular reference)
166
168
 
167
169
  ---
168
170
 
@@ -182,6 +184,8 @@ useWsStore<T>(selector: (state: WsState) => T): T
182
184
  ```ts
183
185
  interface WsState {
184
186
  status: WsStatus;
187
+ /** Provider 連線意圖與重連策略階段;與 `status` 正交 */
188
+ phase: WsPhase;
185
189
  /** 本輪已排程的自動重連次數(意外斷線當下 +1,非重連成功才 +1) */
186
190
  reconnectAttempt: number;
187
191
  /** 本輪自動重連已達 `reconnectMax` 且最後一次也失敗;`connect()` / `disconnect()` 歸 `false` */
@@ -191,9 +195,9 @@ interface WsState {
191
195
  }
192
196
  ```
193
197
 
194
- | 適合放進 store | 不適合 |
195
- | ------------------------------------------------------------------------------------------------------ | ------------------------------------- |
196
- | `status`、重連進度(`reconnectAttempt` / `reconnectExhausted`)、待送佇列長度、探活/stall 等連線健康摘要 | `lastMessage`、訊息歷史、業務 payload |
198
+ | 適合放進 store | 不適合 |
199
+ | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
200
+ | `status`、`phase`、重連進度(`reconnectAttempt` / `reconnectExhausted`)、待送佇列長度、探活/stall 等連線健康摘要 | `lastMessage`、訊息歷史、業務 payload |
197
201
 
198
202
  `CreateWsContextOptions`(如 `url`、`reconnectMax`)在 `createWsContext` 時凍結,**不在** `WsState`;UI 若需顯示 `n/max` 請自行保存設定值,或訂閱時與 store 欄位組合。
199
203
 
@@ -206,10 +210,31 @@ interface WsState {
206
210
  | `open` | 已連線 |
207
211
  | `closed` | 已斷線 |
208
212
 
213
+ 反映 WebSocket 當下的連線狀態(類似 readyState 映射)。**不含**「是否在自動重連週期」「是否為使用者主動斷線」等 provider 意圖——請搭配 `phase`。
214
+
215
+ #### `WsPhase`
216
+
217
+ | 值 | 意義 |
218
+ | --------------- | -------------------------------------------------------------------- |
219
+ | `idle` | 未連線、未排程重連(初始或手動 `disconnect()`) |
220
+ | `connecting` | 首次或手動 `connect()` 連線中 |
221
+ | `open` | 已連線 |
222
+ | `reconnecting` | 自動重連週期(等待計時器或連線中);搭配 `status`、`reconnectAttempt` |
223
+ | `stopped` | 不會再自動重連;`reconnectExhausted` 區分達上限或未啟用重連 |
224
+
225
+ `status` 與 `phase` 常同時變化,但語意不同。例如 `phase === "reconnecting"` 且 `status === "closed"` 表示正在等待重連計時器;`status === "connecting"` 則表示計時器已觸發、正在嘗試連線。
226
+
209
227
  **建議:** 以 selector 只訂閱需要的欄位;state 擴充後可避免不必要的重繪。
210
228
 
211
229
  ```tsx
230
+ const phase = useWsStore((s) => s.phase);
212
231
  const status = useWsStore((s) => s.status);
232
+
233
+ // 手動連線:閒置或已停止時才可點
234
+ const canConnect = phase === "idle" || phase === "stopped";
235
+ // 主動斷線:連線中或重連週期內才可點
236
+ const canDisconnect =
237
+ phase === "open" || phase === "connecting" || phase === "reconnecting";
213
238
  ```
214
239
 
215
240
  ---
@@ -229,6 +254,7 @@ const status = useWsStore((s) => s.status);
229
254
 
230
255
  - `handler` 以 ref 保存最新引用,callback 重建**不會**導致重新訂閱
231
256
  - `type` 變更**會**重新訂閱
257
+ - 意外斷線時,`close` handler 觸發前 store 已更新為 `status: "closed"` 及對應 `phase`(`reconnecting` / `stopped` 等)
232
258
  - 需監聽多種事件時,分別呼叫多次 `useWsEvents`
233
259
 
234
260
  ---
@@ -292,7 +318,8 @@ createWsContext({
292
318
  | `CreateWsContextOptions` | `createWsContext` 的選項 |
293
319
  | `WsContextValue` | `useWsActions()` 回傳型別 |
294
320
  | `WsEvents` | 事件名稱與 handler 的型別對應 |
295
- | `WsStatus` | 連線生命週期狀態聯集(`WsState` 的一環) |
321
+ | `WsStatus` | WebSocket 連線狀態(`WsState` 的一環) |
322
+ | `WsPhase` | Provider 連線意圖與重連策略階段(`WsState` 的一環) |
296
323
  | `WsState` | 可訂閱 store 的 state 形狀(連線健康/佇列/重連) |
297
324
 
298
325
  ---
@@ -343,7 +370,7 @@ sendJson(createStallMessage("stall"));
343
370
  | 項目 | 說明 |
344
371
  | -------------- | ------------------------------------------------------------------------------------------ |
345
372
  | 設定不可變 | `url`、`reconnectMs` 等建立後固定;需換 URL 請另建 context 或手動 `disconnect` + `connect` |
346
- | 重連策略 | 固定間隔,無 exponential backoff、無最大重試次數 |
373
+ | 重連策略 | 固定間隔,無 exponential backoff;`reconnectMax > 0` 可限制次數 |
347
374
  | SSR | 不在 server 建立 `WebSocket`;`connect()` 在 `window` 不存在時為 no-op |
348
375
  | 錯誤狀態 | 不設 `"error"` status;請監聽 `useWsEvents("error")` |
349
376
  | `WsState` 範圍 | 只含連線健康/佇列/重連;訊息與業務資料不走 store |
@@ -376,5 +403,5 @@ sendJson(createStallMessage("stall"));
376
403
  - **授權:** [MIT](https://github.com/ai/nanoevents/blob/main/LICENSE)
377
404
  - **借鑑範圍:**
378
405
  - Typed event emitter — 執行期邏輯幾乎對齊 [`createNanoEvents`](https://github.com/ai/nanoevents/blob/main/index.js);型別為本套件收斂版
379
- - `useEmitter` 為本套件自行新增(React `useState` 包裝)
380
- - **對應原始碼:** `src/ws-context/emitter.ts`
406
+ - `useWsEventsApi` 為本套件自行新增(React `useState` 包裝,見 `ws-events.ts`)
407
+ - **對應原始碼:** `src/ws-context/emitter.ts`、`src/ws-context/ws-events.ts`
package/dist/index.d.mts CHANGED
@@ -11,6 +11,18 @@ import { PropsWithChildren } from "react";
11
11
  * 錯誤用 `useWsEvents("error")`;不另設 error status。
12
12
  */
13
13
  type WsStatus = "idle" | "connecting" | "open" | "closed";
14
+ /**
15
+ * Provider 連線意圖與重連策略階段(`WsState` 的一環)。
16
+ *
17
+ * 與 `status`(WebSocket readyState 映射)正交,補足 UI 無法單靠 `status` 判斷的情境。
18
+ *
19
+ * - `idle` — 未連線、未排程重連(初始或手動 `disconnect()`)
20
+ * - `connecting` — 首次或手動 `connect()` 連線中
21
+ * - `open` — 已連線
22
+ * - `reconnecting` — 自動重連週期(等待計時器或連線中);細節搭配 `status`、`reconnectAttempt`
23
+ * - `stopped` — 不會再自動重連;`reconnectExhausted` 區分達上限或未啟用重連
24
+ */
25
+ type WsPhase = "idle" | "connecting" | "open" | "reconnecting" | "stopped";
14
26
  /**
15
27
  * 可訂閱的連線層 state(低頻更新)。
16
28
  *
@@ -23,6 +35,8 @@ type WsStatus = "idle" | "connecting" | "open" | "closed";
23
35
  type WsState = {
24
36
  /** 連線生命週期狀態 */
25
37
  status: WsStatus;
38
+ /** Provider 連線意圖與重連策略階段 */
39
+ phase: WsPhase;
26
40
  /**
27
41
  * 本輪已排程的自動重連次數(意外斷線當下 +1,非重連成功才 +1)。
28
42
  *
@@ -62,7 +76,7 @@ interface CreateWsContextOptions {
62
76
  /**
63
77
  * 是否自動重新連線。
64
78
  *
65
- * @default true
79
+ * @default true
66
80
  */
67
81
  autoConnect?: boolean;
68
82
  /**
@@ -123,7 +137,7 @@ interface WsContextValue {
123
137
  * 傳送 JSON 資料
124
138
  *
125
139
  * @param data 資料
126
- * @returns 是否已送出或已入隊
140
+ * @returns 是否已送出或已入隊;`JSON.stringify` 失敗(如 circular reference)回傳 `false`
127
141
  */
128
142
  sendJson: (data: unknown) => boolean;
129
143
  /** 建立連線 */
@@ -152,5 +166,5 @@ declare function createWsContext(options: CreateWsContextOptions): {
152
166
  useWsEvents: <E extends keyof WsEvents>(type: E, handler: WsEvents[E]) => void;
153
167
  };
154
168
  //#endregion
155
- export { type CreateWsContextOptions, type WsContextValue, type WsEvents, type WsState, type WsStatus, createWsContext };
169
+ export { type CreateWsContextOptions, type WsContextValue, type WsEvents, type WsPhase, type WsState, type WsStatus, createWsContext };
156
170
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/ws-context/ws-store.ts","../src/ws-context/liveness/types.ts","../src/ws-context/types.ts","../src/ws-context/index.tsx"],"mappings":";;;;;;;;;;;;KAcY;;;;;;;;;;KAWA;;EAEV,QAAQ;;;;;;;;EAQR;;;;;;EAMA;;;;;UCxCe;;EAEf;;EAEA;;EAEA;;EAEA,SAAS;;;;;UCLM;;EAEf;;EAEA;;;;;;EAMA;;;;;;EAMA;;;;;;;;;EASA;;;;;;EAMA;;;;;;EAMA,SAAS,MAAM;;EAEf,WAAW;;UAGI;;;;;;;EAOf,UAAU,eAAe,OAAO;;EAEhC,OAAO,OAAO;;EAEd,QAAQ,OAAO;;EAEf,QAAQ,OAAO;;;UAIA;;;;;;;EAOf,OAAO,MAAM,WAAW;;;;;;;EAOxB,WAAW;;EAEX;;EAEA;;EAEA,iBAAiB;;;;;;;;;;;iBC3CH,gBAAgB,SAAS;EAmBL,eAAA,YAAA,sCAAiB,IAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/ws-context/ws-store.ts","../src/ws-context/liveness/types.ts","../src/ws-context/types.ts","../src/ws-context/index.tsx"],"mappings":";;;;;;;;;;;;KAcY;;;;;;;;;;;;KAaA;;;;;;;;;;KAgBA;;EAEV,QAAQ;;EAER,OAAO;;;;;;;;EAQP;;;;;;EAMA;;;;;UC5De;;EAEf;;EAEA;;EAEA;;EAEA,SAAS;;;;;UCLM;;EAEf;;EAEA;;;;;;EAMA;;;;;;EAMA;;;;;;;;;EASA;;;;;;EAMA;;;;;;EAMA,SAAS,MAAM;;EAEf,WAAW;;UAGI;;;;;;;EAOf,UAAU,eAAe,OAAO;;EAEhC,OAAO,OAAO;;EAEd,QAAQ,OAAO;;EAEf,QAAQ,OAAO;;;UAIA;;;;;;;EAOf,OAAO,MAAM,WAAW;;;;;;;EAOxB,WAAW;;EAEX;;EAEA;;EAEA,iBAAiB;;;;;;;;;;;iBC5CH,gBAAgB,SAAS;EAmBL,eAAA,YAAA,sCAAiB,IAAA"}
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,11 @@ function createStore(initialState) {
173
177
  }
174
178
  };
175
179
  }
180
+ /** partial 內所有 key 值與 state 相同則視為無變更,不通知訂閱者 */
181
+ function hasPartialChanged(state, partial) {
182
+ for (const [key, value] of Object.entries(partial)) if (!Object.is(state[key], value)) return true;
183
+ return false;
184
+ }
176
185
  //#endregion
177
186
  //#region src/ws-context/use-store.ts
178
187
  function useStore(store, selector) {
@@ -183,18 +192,19 @@ function useStore(store, selector) {
183
192
  function createWsStore(init = "idle") {
184
193
  return createStore({
185
194
  status: init,
195
+ phase: init === "open" || init === "idle" ? init : "connecting",
186
196
  reconnectAttempt: 0,
187
197
  reconnectExhausted: false
188
198
  });
189
199
  }
200
+ function createWsStoreContext() {
201
+ return createContext(null);
202
+ }
190
203
  /** 每個 `WsProvider` 各有一份 {@link WsState} store */
191
204
  function useWsStoreApi() {
192
205
  const [store] = useState(() => createWsStore());
193
206
  return store;
194
207
  }
195
- function createWsStoreContext() {
196
- return createContext(null);
197
- }
198
208
  /** 訂閱 {@link WsState};建議以 selector 只取需要的連線層欄位。 */
199
209
  function createUseWsStore(StoreCtx) {
200
210
  function useWsStore(selector) {
@@ -206,6 +216,7 @@ function createUseWsStore(StoreCtx) {
206
216
  }
207
217
  //#endregion
208
218
  //#region src/ws-context/ws-actions.ts
219
+ /** actions 無獨立 instance;由 WsProvider 以 useMemo 組裝後注入 Context */
209
220
  function createWsActionsContext() {
210
221
  return createContext(null);
211
222
  }
@@ -238,8 +249,10 @@ function createReconnect(reconnectMs, reconnectMax, callbacks) {
238
249
  onConnectBegin() {
239
250
  clearTimer();
240
251
  intentionalClose = false;
252
+ const reconnecting = fromTimer;
241
253
  if (!fromTimer) resetCycle();
242
254
  fromTimer = false;
255
+ return reconnecting;
243
256
  },
244
257
  onOpen() {
245
258
  resetCycle();
@@ -308,16 +321,16 @@ function defaultParse(data) {
308
321
  */
309
322
  function createWsContext(options) {
310
323
  const { url, protocols, autoConnect = true, reconnectMs = 0, reconnectMax = 0, outgoingQueueMax = 0, parse = defaultParse, liveness } = options;
311
- const ActionsCtx = createWsActionsContext();
312
- const useWsActions = createUseWsActions(ActionsCtx);
313
324
  const StoreCtx = createWsStoreContext();
314
325
  const useWsStore = createUseWsStore(StoreCtx);
315
- const EmitterCtx = createWsEventsContext();
316
- const useWsEvents = createUseWsEvents(EmitterCtx);
326
+ const ActionsCtx = createWsActionsContext();
327
+ const useWsActions = createUseWsActions(ActionsCtx);
328
+ const EventsCtx = createWsEventsContext();
329
+ const useWsEvents = createUseWsEvents(EventsCtx);
317
330
  function WsProvider({ children }) {
318
331
  const wsRef = useRef(null);
319
332
  const store = useWsStoreApi();
320
- const emitter = useEmitter();
333
+ const emitter = useWsEventsApi();
321
334
  const reconnect = useReconnect(reconnectMs, reconnectMax, {
322
335
  getAttempt: () => store.getState().reconnectAttempt,
323
336
  setAttempt: (reconnectAttempt) => store.setState({ reconnectAttempt }),
@@ -325,31 +338,33 @@ function createWsContext(options) {
325
338
  });
326
339
  const outgoingQueue = useOutgoingQueue(outgoingQueueMax);
327
340
  const livenessSession = useLiveness(liveness, () => wsRef.current);
328
- const setStatus = useCallback((status) => {
329
- store.setState({ status });
330
- }, [store]);
331
341
  const getStatus = useCallback(() => store.getState().status, [store]);
332
- const disconnect = useCallback(() => {
342
+ /** 主動斷線與 Provider unmount 共用;`reason` 區分 `"client disconnect"` / `"provider unmount"` */
343
+ const teardown = useCallback((reason) => {
333
344
  reconnect.cancel();
334
345
  livenessSession.stop();
335
346
  outgoingQueue.clear();
347
+ store.setState({
348
+ phase: "idle",
349
+ status: "closed"
350
+ });
336
351
  const ws = wsRef.current;
337
352
  wsRef.current = null;
338
353
  if (ws) {
339
354
  detachAndClose(ws);
340
- setStatus("closed");
341
- emitter.emit("close", clientCloseEvent("client disconnect"));
342
- } else setStatus("closed");
355
+ emitter.emit("close", clientCloseEvent(reason));
356
+ }
343
357
  }, [
344
- setStatus,
358
+ store,
345
359
  emitter,
346
360
  outgoingQueue,
347
361
  livenessSession,
348
362
  reconnect
349
363
  ]);
364
+ const disconnect = useCallback(() => teardown("client disconnect"), [teardown]);
350
365
  const connect = useCallback(() => {
351
366
  if (typeof window === "undefined") return;
352
- reconnect.onConnectBegin();
367
+ const fromReconnect = reconnect.onConnectBegin();
353
368
  livenessSession.stop();
354
369
  const prev = wsRef.current;
355
370
  if (prev) {
@@ -357,15 +372,21 @@ function createWsContext(options) {
357
372
  detachAndClose(prev);
358
373
  emitter.emit("close", clientCloseEvent("reconnect"));
359
374
  }
360
- setStatus("connecting");
375
+ store.setState({
376
+ status: "connecting",
377
+ phase: fromReconnect ? "reconnecting" : "connecting"
378
+ });
361
379
  const ws = protocols ? new WebSocket(url, protocols) : new WebSocket(url);
362
380
  wsRef.current = ws;
363
381
  ws.onopen = (event) => {
364
382
  if (wsRef.current !== ws) return;
365
383
  reconnect.onOpen();
366
- setStatus("open");
384
+ store.setState({
385
+ status: "open",
386
+ phase: "open"
387
+ });
367
388
  outgoingQueue.flush((data) => ws.send(data));
368
- livenessSession.start(ws);
389
+ livenessSession.start();
369
390
  emitter.emit("open", event);
370
391
  };
371
392
  ws.onmessage = (event) => {
@@ -381,12 +402,15 @@ function createWsContext(options) {
381
402
  ws.onclose = (event) => {
382
403
  if (wsRef.current === ws) wsRef.current = null;
383
404
  livenessSession.stop();
384
- setStatus("closed");
405
+ const scheduled = reconnect.scheduleAfterClose();
406
+ const patch = { status: "closed" };
407
+ if (scheduled) patch.phase = "reconnecting";
408
+ else if (store.getState().phase !== "idle") patch.phase = "stopped";
409
+ store.setState(patch);
385
410
  emitter.emit("close", event);
386
- reconnect.scheduleAfterClose();
387
411
  };
388
412
  }, [
389
- setStatus,
413
+ store,
390
414
  emitter,
391
415
  outgoingQueue,
392
416
  livenessSession,
@@ -395,24 +419,8 @@ function createWsContext(options) {
395
419
  reconnect.bindOnReconnect(connect);
396
420
  useEffect(() => {
397
421
  if (autoConnect) connect();
398
- return () => {
399
- reconnect.cancel();
400
- livenessSession.stop();
401
- outgoingQueue.clear();
402
- const ws = wsRef.current;
403
- wsRef.current = null;
404
- if (ws) {
405
- detachAndClose(ws);
406
- emitter.emit("close", clientCloseEvent("provider unmount"));
407
- }
408
- };
409
- }, [
410
- connect,
411
- emitter,
412
- outgoingQueue,
413
- livenessSession,
414
- reconnect
415
- ]);
422
+ return () => teardown("provider unmount");
423
+ }, [connect, teardown]);
416
424
  const send = useCallback((data) => {
417
425
  const ws = wsRef.current;
418
426
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -421,7 +429,13 @@ function createWsContext(options) {
421
429
  }
422
430
  return outgoingQueue.enqueue(data);
423
431
  }, [outgoingQueue]);
424
- const sendJson = useCallback((data) => send(JSON.stringify(data)), [send]);
432
+ const sendJson = useCallback((data) => {
433
+ try {
434
+ return send(JSON.stringify(data));
435
+ } catch {
436
+ return false;
437
+ }
438
+ }, [send]);
425
439
  const actions = useMemo(() => ({
426
440
  send,
427
441
  sendJson,
@@ -439,7 +453,7 @@ function createWsContext(options) {
439
453
  value: actions,
440
454
  children: /* @__PURE__ */ jsx(StoreCtx.Provider, {
441
455
  value: store,
442
- children: /* @__PURE__ */ jsx(EmitterCtx.Provider, {
456
+ children: /* @__PURE__ */ jsx(EventsCtx.Provider, {
443
457
  value: emitter,
444
458
  children
445
459
  })
@@ -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 * 可訂閱的連線層 state(低頻更新)。\n *\n * 只放:**連線健康**、**outbound 佇列**、**重連** 等連線生命週期資訊。\n *\n * 不放:訊息 payload、訊息歷史、業務資料(請用 `useWsEvents` 或自行管理 state)。\n *\n * 未來可能擴充例如 `pendingCount`;新增欄位時請維持低頻、可 selector 訂閱。\n */\nexport type WsState = {\n /** 連線生命週期狀態 */\n status: WsStatus;\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 return createStore<WsState>({\n status: init,\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 /** 開始連線時呼叫 */\n onConnectBegin: () => void;\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 if (!fromTimer) resetCycle();\n fromTimer = false;\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 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 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 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, reconnect]);\n\n const connect = useCallback<WsContextValue[\"connect\"]>(() => {\n if (typeof window === \"undefined\") return;\n\n 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\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 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 reconnect.scheduleAfterClose();\n };\n }, [setStatus, emitter, outgoingQueue, livenessSession, reconnect]);\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\n/** partial 內所有 key 值與 state 相同則視為無變更,不通知訂閱者 */\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\"\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 =\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 { createUseWsEvents, createWsEventsContext, useWsEventsApi } 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\";\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 /** 主動斷線與 Provider unmount 共用;`reason` 區分 `\"client disconnect\"` / `\"provider unmount\"` */\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 } = { status: \"closed\" };\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":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-ws-context",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
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",