react-ws-context 0.4.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/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.1] - 2026-08-30
11
+
12
+ ### Added
13
+
14
+ - Export `LivenessOptions` from the package entry
15
+
16
+ ### Changed
17
+
18
+ - README (EN / zh-TW): align structure and wording; fix immutable-config URL guidance; clarify render isolation, stall parsing, and `WsProvider` lifecycle
19
+ - JSDoc (`CreateWsContextOptions`, `WsContextValue`): clearer field descriptions; fix `autoConnect` wording
20
+
10
21
  ## [0.4.0] - 2026-08-29
11
22
 
12
23
  ### Fixed
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  > **繁體中文:** [README.zh-TW.md](./README.zh-TW.md)
7
7
 
8
- A React **WebSocket connection layer**. It separates connection lifecycle, subscribable state, and message events so status updates or high-frequency messages do not re-render your entire component tree.
8
+ A React **WebSocket connection-layer** package. It separates connection lifecycle, subscribable state, and message events so connection status or high-frequency messages do not re-render your entire component tree.
9
9
 
10
10
  > **Maintainer:** [GaiaYang](https://github.com/GaiaYang)
11
11
  > **Source:** [github.com/GaiaYang/react-ws](https://github.com/GaiaYang/react-ws) (package path: `packages/react-ws`)
@@ -14,7 +14,7 @@ A React **WebSocket connection layer**. It separates connection lifecycle, subsc
14
14
 
15
15
  - **Zero runtime dependencies** — only `react >= 18` as a peer dependency
16
16
  - **Frozen config** — `url`, `reconnectMs`, etc. are fixed at `createWsContext`; use `connect` / `disconnect` at runtime
17
- - **Render isolation** — connection-layer state (health / queue / reconnect) lives in an external store; messages go through an event emitter, not React Context
17
+ - **Render isolation** — connection-layer state (health / queue / reconnect) lives in an external store; messages are delivered through an event emitter and subscribed via `useWsEvents` (not written into React state)
18
18
  - **Optional liveness** — periodic ping / pong; closes the socket on timeout to trigger reconnect
19
19
  - **Optional outbound queue** — buffers messages while not OPEN, flushes on connect
20
20
 
@@ -87,13 +87,13 @@ function Chat() {
87
87
  createWsContext(options)
88
88
 
89
89
  ├── WsProvider WebSocket instance, reconnect, liveness, outbound queue
90
- ├── useWsActions() send / connect / disconnect — no re-renders
90
+ ├── useWsActions() send / connect / disconnect / getStatus — no re-renders
91
91
  ├── useWsStore() connection-layer state: health / queue / reconnect
92
92
  └── useWsEvents() open / message / error / close
93
93
  ```
94
94
 
95
95
  - **Call `createWsContext` multiple times** for independent connections (e.g. app WS + notification WS).
96
- - **Provider instances** — `useWsStoreApi` (store), `useWsEventsApi` (emitter); actions are assembled in `WsProvider` via `useMemo` and passed through Context.
96
+ - **Provider internals** — each `WsProvider` owns a separate store and event emitter (not public API); actions are assembled in `WsProvider` via `useMemo` and passed through Context.
97
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.
98
98
  - **Messages and errors** — use `useWsEvents`; keep message history in your own state, cache, or store.
99
99
  - **Connection errors are not a `WsStatus`** — use `useWsEvents("error")`; native `error` is usually followed by `close`.
@@ -108,16 +108,16 @@ Creates a `WsProvider` and hooks bound to the same connection config.
108
108
 
109
109
  #### `CreateWsContextOptions`
110
110
 
111
- | Field | Type | Default | Description |
112
- | ------------------ | ----------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- |
113
- | `url` | `string` | (required) | WebSocket URL |
114
- | `protocols` | `string \| string[]` | — | Passed to `new WebSocket(url, protocols)` |
115
- | `autoConnect` | `boolean` | `true` | Call `connect()` after `WsProvider` mounts |
116
- | `reconnectMs` | `number` | `0` | Reconnect delay (ms) after unintentional close; `0` disables reconnect |
117
- | `reconnectMax` | `number` | `0` | Max auto-reconnects after unintentional close (excludes initial connect); `0` unlimited. `reconnectAttempt` resets on open, manual `connect()`, or `disconnect()` |
118
- | `outgoingQueueMax` | `number` | `0` | Max outbound queue size while not OPEN; `0` disables the queue |
119
- | `parse` | `(data: MessageEvent["data"]) => unknown` | see below | Transform raw `MessageEvent.data` |
120
- | `liveness` | `LivenessOptions` | — | Liveness / heartbeat config; omit to disable |
111
+ | Field | Type | Default | Description |
112
+ | ------------------ | ----------------------------------------- | ---------- | ----------------------------------------------------------------------------------------- |
113
+ | `url` | `string` | (required) | WebSocket URL |
114
+ | `protocols` | `string \| string[]` | — | Passed to `new WebSocket(url, protocols)` |
115
+ | `autoConnect` | `boolean` | `true` | Auto-connect when `WsProvider` loads |
116
+ | `reconnectMs` | `number` | `0` | Reconnect delay (ms) after unintentional close; `0` disables reconnect |
117
+ | `reconnectMax` | `number` | `0` | Max auto-reconnects after unintentional close; `0` unlimited (requires `reconnectMs > 0`) |
118
+ | `outgoingQueueMax` | `number` | `0` | Max outbound queue size while not OPEN; `0` disables the queue |
119
+ | `parse` | `(data: MessageEvent["data"]) => unknown` | see below | Transform raw `MessageEvent.data` |
120
+ | `liveness` | `LivenessOptions` | — | Liveness / heartbeat config; omit to disable |
121
121
 
122
122
  **Default `parse`:**
123
123
 
@@ -139,13 +139,13 @@ Creates a `WsProvider` and hooks bound to the same connection config.
139
139
 
140
140
  Creates, owns, and tears down the native `WebSocket`.
141
141
 
142
- | Behavior | Description |
143
- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
144
- | mount + `autoConnect: true` | Calls `connect()` |
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"`) |
147
- | reconnect | Fixed interval when `reconnectMs > 0` and close was not intentional (no exponential backoff); stops after `reconnectMax` if `> 0` |
148
- | before reconnect | Closes existing socket and emits `close` (reason: `"reconnect"`) |
142
+ | Behavior | Description |
143
+ | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
144
+ | `WsProvider` loads + `autoConnect: true` | Auto-connects |
145
+ | unmount | Cancels reconnect (`reconnectAttempt` and `reconnectExhausted` reset), stops liveness, clears outbound queue; syncs store to `status: "closed"`, `phase: "idle"`; closes socket and emits `close` (reason: `"provider unmount"`) |
146
+ | `disconnect()` | Same cleanup and store reset as unmount, no auto-reconnect; emits `close` (reason: `"client disconnect"`) |
147
+ | reconnect | Fixed interval when `reconnectMs > 0` and close was not intentional (no exponential backoff); stops after `reconnectMax` if `> 0` |
148
+ | `connect()` with existing socket | Closes the previous socket and emits `close` (reason: `"reconnect"`) before opening a new one (manual connect or auto-reconnect) |
149
149
 
150
150
  ---
151
151
 
@@ -153,27 +153,29 @@ Creates, owns, and tears down the native `WebSocket`.
153
153
 
154
154
  Must be used inside the matching `WsProvider`. Return value is memoized and **does not** re-render on store or message updates.
155
155
 
156
- | Method | Signature | Description |
157
- | ------------ | ---------------------------- | ---------------------------------------------------------------------------- |
158
- | `send` | `(data) => boolean` | Send raw data. Sends immediately when OPEN; otherwise enqueues if configured |
159
- | `sendJson` | `(data: unknown) => boolean` | `JSON.stringify` then `send` |
160
- | `connect` | `() => void` | Open connection; closes any existing socket first |
161
- | `disconnect` | `() => void` | Intentional close; sets store to `phase: "idle"`, `status: "closed"`; no auto-reconnect; clears outbound queue |
162
- | `getStatus` | `() => WsStatus` | Read current status; no subscription, no re-render |
156
+ | Method | Signature | Description |
157
+ | ------------ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------- |
158
+ | `send` | `(data) => boolean` | Send raw data (`string`, `ArrayBuffer`, `Blob`, etc.). Sends immediately when OPEN; otherwise enqueues if configured |
159
+ | `sendJson` | `(data: unknown) => boolean` | `JSON.stringify` then `send`; same return semantics as `send`; `false` if not serializable |
160
+ | `connect` | `() => void` | Open connection; closes any existing socket first (see `WsProvider`) |
161
+ | `disconnect` | `() => void` | Intentional close; sets store to `phase: "idle"`, `status: "closed"`; no auto-reconnect; clears outbound queue |
162
+ | `getStatus` | `() => WsStatus` | Read current status; no subscription, no re-render |
163
163
 
164
164
  **`send` / `sendJson` return value:**
165
165
 
166
166
  - `true` — sent or enqueued
167
- - `false` — not OPEN and queue full (`outgoingQueueMax > 0`), queue disabled (`outgoingQueueMax === 0`), or `sendJson` failed to `JSON.stringify` (e.g. circular reference)
167
+ - `false` — not sent: queue full, queue disabled, or `sendJson` could not serialize
168
168
 
169
169
  ---
170
170
 
171
171
  ### `useWsStore()`
172
172
 
173
- Must be used inside the matching `WsProvider`. Uses `useSyncExternalStore` under the hood.
173
+ Must be used inside the matching `WsProvider`. Uses `useSyncExternalStore` under the hood; **partial updates with unchanged values do not notify subscribers** (shallow compare).
174
174
 
175
175
  `WsState` is for **connection health / outbound queue / reconnect** — low-frequency lifecycle data. For high-frequency messages, use `useWsEvents("message", …)`, not the store.
176
176
 
177
+ **Tip:** use a selector to subscribe only to the fields you need (e.g. `(s) => s.phase`). `useWsStore()` without a selector subscribes to the full state — any field change triggers a re-render.
178
+
177
179
  ```ts
178
180
  useWsStore(): WsState
179
181
  useWsStore<T>(selector: (state: WsState) => T): T
@@ -195,9 +197,9 @@ interface WsState {
195
197
  }
196
198
  ```
197
199
 
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 |
200
+ | Belongs in store | Does not belong |
201
+ | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
202
+ | `status`, `phase`, reconnect progress (`reconnectAttempt` / `reconnectExhausted`), liveness / stall summaries | `lastMessage`, message history, app payloads |
201
203
 
202
204
  `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.
203
205
 
@@ -214,17 +216,17 @@ Maps to the current WebSocket connection state (similar to readyState). Does **n
214
216
 
215
217
  #### `WsPhase`
216
218
 
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 |
219
+ | Value | Meaning |
220
+ | -------------- | -------------------------------------------------------------------------------------------------- |
221
+ | `idle` | Not connected, no reconnect scheduled (initial or manual `disconnect()`) |
222
+ | `connecting` | First connect or manual `connect()` in progress |
223
+ | `open` | Connected |
224
+ | `reconnecting` | Auto-reconnect cycle (waiting for timer or connecting); pair with `status`, `reconnectAttempt` |
225
+ | `stopped` | Will not auto-reconnect; use `reconnectExhausted` to distinguish max retries vs reconnect disabled |
224
226
 
225
227
  `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
228
 
227
- **Tip:** use a selector to subscribe to only the fields you need.
229
+ **Example:**
228
230
 
229
231
  ```tsx
230
232
  const phase = useWsStore((s) => s.phase);
@@ -255,20 +257,23 @@ Must be used inside the matching `WsProvider`. Registers in `useEffect` and unsu
255
257
  - Handler is kept in a ref — changing the callback does **not** re-subscribe
256
258
  - Changing `type` **does** re-subscribe
257
259
  - On unintentional close, the store is updated to `status: "closed"` and the appropriate `phase` before the `close` handler runs
260
+ - Intentional `disconnect()` or provider unmount follows the same order: store first, then `close`
258
261
  - For multiple events, call `useWsEvents` multiple times
259
262
 
260
263
  ---
261
264
 
262
- ### Liveness: `LivenessOptions`
265
+ ### Liveness
266
+
267
+ Enable via `createWsContext({ liveness: { … } })`. After OPEN, sends periodic application-layer pings (JSON via `send`, not WebSocket control frames); if no matching pong within `timeoutMs`, closes the socket (which can trigger reconnect).
263
268
 
264
- Enable via `createWsContext({ liveness: { … } })`. After OPEN, sends periodic pings; if no matching pong within `timeoutMs`, closes the socket (which can trigger reconnect).
269
+ Shape of the `liveness` option:
265
270
 
266
271
  ```ts
267
272
  interface LivenessOptions {
268
- intervalMs: number;
269
- timeoutMs: number;
270
- ping: unknown | (() => unknown);
271
- isPong: (data: unknown) => boolean;
273
+ intervalMs: number; // ping interval (ms)
274
+ timeoutMs: number; // wait for pong (ms)
275
+ ping: unknown | (() => unknown); // ping payload; function for dynamic values
276
+ isPong: (data: unknown) => boolean; // whether parsed data is a pong
272
277
  }
273
278
  ```
274
279
 
@@ -290,7 +295,7 @@ createWsContext({
290
295
  });
291
296
  ```
292
297
 
293
- Every incoming message is checked with `isPong`; a pong resets the timeout timer and still emits `"message"`.
298
+ Every incoming message is checked with `isPong`; a pong clears the timeout timer and still emits `"message"`. Ping payloads are always sent via `JSON.stringify` (JSON only).
294
299
 
295
300
  ---
296
301
 
@@ -298,14 +303,14 @@ Every incoming message is checked with `isPong`; a pong resets the timeout timer
298
303
 
299
304
  When `outgoingQueueMax > 0`:
300
305
 
301
- | When | Behavior |
302
- | -------------------------- | ------------------------------------------------- |
303
- | `send` while not OPEN | Enqueue (FIFO) |
304
- | Queue full | Returns `false`; does **not** drop older messages |
305
- | Socket OPEN | Flush entire queue in order |
306
- | `disconnect()` | Clear queue |
307
- | `WsProvider` unmount | Clear queue |
308
- | Waiting for auto-reconnect | **Keep** queue |
306
+ | When | Behavior |
307
+ | -------------------------- | -------------------------------------------------------------- |
308
+ | `send` while not OPEN | Enqueue (FIFO) |
309
+ | Queue full | Returns `false`; does **not** drop older messages |
310
+ | Socket OPEN | Flush entire queue in order |
311
+ | `disconnect()` | Clear queue; store set to `idle` / `closed` (see `WsProvider`) |
312
+ | `WsProvider` unmount | Clear queue; store synced (see `WsProvider`) |
313
+ | Waiting for auto-reconnect | **Keep** queue |
309
314
 
310
315
  ---
311
316
 
@@ -313,14 +318,15 @@ When `outgoingQueueMax > 0`:
313
318
 
314
319
  From the main `react-ws-context` entry:
315
320
 
316
- | Type | Description |
317
- | ------------------------ | ----------------------------------------------------- |
318
- | `CreateWsContextOptions` | Options for `createWsContext` |
319
- | `WsContextValue` | Return type of `useWsActions()` |
320
- | `WsEvents` | Event name handler map |
321
- | `WsStatus` | WebSocket connection state (`WsState`) |
321
+ | Type | Description |
322
+ | ------------------------ | -------------------------------------------------------- |
323
+ | `CreateWsContextOptions` | Options for `createWsContext` |
324
+ | `LivenessOptions` | Options for `liveness` in `createWsContext` |
325
+ | `WsContextValue` | Return type of `useWsActions()` |
326
+ | `WsEvents` | Event name handler map |
327
+ | `WsStatus` | WebSocket connection state (`WsState`) |
322
328
  | `WsPhase` | Provider connection intent / reconnect phase (`WsState`) |
323
- | `WsState` | Subscribable store shape (health / queue / reconnect) |
329
+ | `WsState` | Subscribable store shape (health / queue / reconnect) |
324
330
 
325
331
  ---
326
332
 
@@ -340,28 +346,42 @@ import {
340
346
  } from "react-ws-context/stall";
341
347
  ```
342
348
 
343
- | Export | Description |
344
- | ---------------------------- | ------------------------------------------------------------- |
345
- | `STALL_MESSAGE_TYPE` | Client control message type (`"STALL"`) |
346
- | `STALL_ACK_TYPE` | Server ack type (`"STALL_ACK"`) |
347
- | `createStallMessage(action)` | Build a message for `sendJson` |
348
- | `parseStallMessage(data)` | Parse from `useWsEvents("message")` data; `null` if invalid |
349
- | `StallAction` | `"stall" \| "release"` |
350
- | `StallMessage` | `{ type: "STALL"; action: StallAction }` |
351
- | `StallAck` | `{ type: "STALL_ACK"; action: StallAction; active: boolean }` |
349
+ | Export | Description |
350
+ | ---------------------------- | ----------------------------------------------------------------------------------------------------- |
351
+ | `STALL_MESSAGE_TYPE` | Client control message type (`"STALL"`) |
352
+ | `STALL_ACK_TYPE` | Server ack type (`"STALL_ACK"`) — for typing only; no built-in parser |
353
+ | `createStallMessage(action)` | Build a client `STALL` message for `sendJson` |
354
+ | `parseStallMessage(data)` | Parse client `STALL` messages from `useWsEvents("message")` data; `null` if invalid (not `STALL_ACK`) |
355
+ | `StallAction` | `"stall" \| "release"` |
356
+ | `StallMessage` | `{ type: "STALL"; action: StallAction }` |
357
+ | `StallAck` | `{ type: "STALL_ACK"; action: StallAction; active: boolean }` — parse server acks yourself |
358
+
359
+ **Example:**
360
+
361
+ ```tsx
362
+ const { sendJson } = useWsActions();
363
+
364
+ useWsEvents("message", (data) => {
365
+ const stall = parseStallMessage(data);
366
+ if (stall) console.log("stall control", stall.action);
367
+ });
368
+
369
+ sendJson(createStallMessage("stall"));
370
+ ```
352
371
 
353
372
  ---
354
373
 
355
374
  ## Design trade-offs
356
375
 
357
- | Topic | Notes |
358
- | ---------------- | ------------------------------------------------------------------------------ |
359
- | Immutable config | `url`, `reconnectMs`, etc. are fixed at create time |
360
- | Reconnect | Fixed interval only; no exponential backoff; optional cap via `reconnectMax` |
361
- | SSR | No `WebSocket` on the server; `connect()` is a no-op without `window` |
362
- | Error status | No `"error"` in `WsStatus`; use `useWsEvents("error")` |
363
- | `WsState` scope | Health / queue / reconnect only — not messages or app data |
364
- | Rendering | Components that only call `useWsActions` do not re-render on store or messages |
376
+ | Topic | Notes |
377
+ | ---------------- | --------------------------------------------------------------------------------------------------------- |
378
+ | Immutable config | `url`, `reconnectMs`, etc. are fixed at create time; to use a different URL, call `createWsContext` again |
379
+ | Reconnect | Fixed interval only; no exponential backoff; optional cap via `reconnectMax` |
380
+ | SSR | No `WebSocket` on the server; `connect()` is a no-op without `window` |
381
+ | Error status | No `"error"` in `WsStatus`; use `useWsEvents("error")` |
382
+ | `WsState` scope | Health / queue / reconnect only — not messages or app data |
383
+ | Rendering | Components that only call `useWsActions` do not re-render on store or messages |
384
+ | Store updates | Repeated writes of the same field values do not notify; prefer selectors |
365
385
 
366
386
  ---
367
387
 
@@ -380,13 +400,13 @@ This package does **not** list zustand or nanoevents as npm dependencies. It inl
380
400
  - **Maintainer:** [pmndrs](https://github.com/pmndrs) (Poimandres)
381
401
  - **License:** [MIT](https://github.com/pmndrs/zustand/blob/main/LICENSE)
382
402
  - **Adapted from:**
383
- - External store API — aligned with [`vanilla.ts`](https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts) (subset only)
384
- - React subscription — inspired by [`react.ts`](https://github.com/pmndrs/zustand/blob/main/src/react.ts) `useStore`
403
+ - External store API — aligned with [`vanilla.ts`](https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts) (subset only; no middleware, replace, or initializer factory); `setState` adds partial shallow dedup (unchanged values skip notification)
404
+ - React subscription — inspired by [`react.ts`](https://github.com/pmndrs/zustand/blob/main/src/react.ts) `useStore` (no `useDebugValue`); optional selector overload lives in `createUseWsStore`
385
405
  - **Files:** `src/ws-context/store.ts`, `src/ws-context/use-store.ts`
386
406
 
387
407
  ### [nanoevents](https://github.com/ai/nanoevents)
388
408
 
389
409
  - **Author:** [Andrey Sitnik](https://github.com/ai) (`ai`)
390
410
  - **License:** [MIT](https://github.com/ai/nanoevents/blob/main/LICENSE)
391
- - **Adapted from:** [`createNanoEvents`](https://github.com/ai/nanoevents/blob/main/index.js); `useWsEventsApi` added by this package (`ws-events.ts`)
411
+ - **Adapted from:** [`createNanoEvents`](https://github.com/ai/nanoevents/blob/main/index.js); React subscription wrapper added in `ws-events.ts`
392
412
  - **Files:** `src/ws-context/emitter.ts`, `src/ws-context/ws-events.ts`
package/README.zh-TW.md CHANGED
@@ -1,11 +1,11 @@
1
- > **English:** [README.md](./README.md)
2
-
3
1
  # react-ws-context
4
2
 
5
3
  [![npm version](https://img.shields.io/npm/v/react-ws-context.svg)](https://www.npmjs.com/package/react-ws-context)
6
4
  [![npm downloads](https://img.shields.io/npm/dm/react-ws-context.svg)](https://www.npmjs.com/package/react-ws-context)
7
5
 
8
- React 用的 WebSocket **連線層**套件。將連線生命週期、可訂閱狀態與訊息事件分離,避免 status 或高頻訊息更新拖垮整棵元件樹。
6
+ > **English:** [README.md](./README.md)
7
+
8
+ React 用的 **WebSocket 連線層**套件。將連線生命週期、可訂閱狀態與訊息事件分離,避免連線 status 或高頻訊息更新拖垮整棵元件樹。
9
9
 
10
10
  > **維護者:** [GaiaYang](https://github.com/GaiaYang)
11
11
  > **原始碼:** [github.com/GaiaYang/react-ws](https://github.com/GaiaYang/react-ws)(monorepo 內路徑 `packages/react-ws`)
@@ -14,7 +14,7 @@ React 用的 WebSocket **連線層**套件。將連線生命週期、可訂閱
14
14
 
15
15
  - **零 runtime 依賴** — 僅需 `react >= 18`(peer dependency)
16
16
  - **設定凍結** — `url`、`reconnectMs` 等在 `createWsContext` 時固定;執行期以 `connect` / `disconnect` 控制
17
- - **渲染隔離** — 連線層 state(健康/佇列/重連)走外部 store;訊息走 event emitter,不進 React Context
17
+ - **渲染隔離** — 連線層 state(健康/佇列/重連)走外部 store;訊息經 event emitter 傳遞,以 `useWsEvents` 訂閱(不寫入 React state)
18
18
  - **可選探活** — 週期性 ping/pong 偵測,逾時主動關閉 socket 以觸發重連
19
19
  - **可選 outbound 佇列** — 未 OPEN 時暫存待送訊息,連線成功後 flush
20
20
 
@@ -87,13 +87,13 @@ function Chat() {
87
87
  createWsContext(options)
88
88
 
89
89
  ├── WsProvider 管理 WebSocket 實例、重連、探活、outbound 佇列
90
- ├── useWsActions() 連線操作(send / connect / disconnect),不觸發重繪
91
- ├── useWsStore() 訂閱連線層 state:健康/佇列/重連(useSyncExternalStore)
92
- └── useWsEvents() 訂閱 open / message / error / close 事件
90
+ ├── useWsActions() 連線操作(send / connect / disconnect / getStatus),不觸發重繪
91
+ ├── useWsStore() 訂閱連線層 state:健康/佇列/重連
92
+ └── useWsEvents() 訂閱 open / message / error / close 事件
93
93
  ```
94
94
 
95
95
  - **同一應用可多次呼叫 `createWsContext`**,每次產生一組互不共用的 Provider 與 hooks(例如同時連業務 WS 與通知 WS)。
96
- - **Provider 內部 instance** — `useWsStoreApi`(store)、`useWsEventsApi`(emitter);actions 在 `WsProvider` 內以 `useMemo` 組裝後注入 Context。
96
+ - **Provider 內部**`WsProvider` 持有獨立的 store 與 event emitter(非公開 API);actions 在 `WsProvider` 內以 `useMemo` 組裝後注入 Context。
97
97
  - **`WsState` 只放連線層、低頻欄位** — 連線健康(`status`、`phase`)、outbound 佇列(如未來 `pendingCount`)、重連(`reconnectAttempt`)。**不放**訊息 payload 或業務資料。
98
98
  - **訊息與錯誤事件** — 請用 `useWsEvents`;訊息歷史請自行寫入 state、cache 或外部 store。
99
99
  - **連線錯誤不反映在 `WsStatus`** — 請用 `useWsEvents("error", …)` 處理;原生 `error` 事件後通常緊接 `close`。
@@ -108,16 +108,16 @@ createWsContext(options)
108
108
 
109
109
  #### 參數:`CreateWsContextOptions`
110
110
 
111
- | 欄位 | 型別 | 預設 | 說明 |
112
- | ------------------ | ----------------------------------------- | -------- | --------------------------------------------------------------------------------------------- |
113
- | `url` | `string` | (必填) | WebSocket 連線網址 |
114
- | `protocols` | `string \| string[]` | — | 傳入 `new WebSocket(url, protocols)` 的子協定 |
115
- | `autoConnect` | `boolean` | `true` | `WsProvider` mount 後是否自動呼叫 `connect()` |
116
- | `reconnectMs` | `number` | `0` | 非主動斷線後的重連間隔(毫秒);`0` 表示不重連 |
117
- | `reconnectMax` | `number` | `0` | 非主動斷線後最多自動重連幾次(不含首次連線);`0` 不限制。`reconnectAttempt` 於成功 `open`、手動 `connect()` `disconnect()` 歸零 |
118
- | `outgoingQueueMax` | `number` | `0` | 未 OPEN 時 outbound 佇列上限;`0` 關閉佇列 |
119
- | `parse` | `(data: MessageEvent["data"]) => unknown` | 見下方 | 將原始 `MessageEvent.data` 轉成業務資料 |
120
- | `liveness` | `LivenessOptions` | — | 探活設定;省略則不啟用 |
111
+ | 欄位 | 型別 | 預設 | 說明 |
112
+ | ------------------ | ----------------------------------------- | -------- | ---------------------------------------------------------------- |
113
+ | `url` | `string` | (必填) | WebSocket 連線網址 |
114
+ | `protocols` | `string \| string[]` | — | 傳入 `new WebSocket(url, protocols)` 的子協定 |
115
+ | `autoConnect` | `boolean` | `true` | WsProvider 載入時是否自動連線 |
116
+ | `reconnectMs` | `number` | `0` | 非主動斷線後的重連間隔(毫秒);`0` 表示不重連 |
117
+ | `reconnectMax` | `number` | `0` | 非主動斷線後最多自動重連幾次;`0` 不限制(需 `reconnectMs > 0`) |
118
+ | `outgoingQueueMax` | `number` | `0` | 未 OPEN 時 outbound 佇列上限;`0` 關閉佇列 |
119
+ | `parse` | `(data: MessageEvent["data"]) => unknown` | 見下方 | 將原始 `MessageEvent.data` 轉成業務資料 |
120
+ | `liveness` | `LivenessOptions` | — | 探活設定;省略則不啟用 |
121
121
 
122
122
  **預設 `parse` 行為:**
123
123
 
@@ -139,13 +139,13 @@ createWsContext(options)
139
139
 
140
140
  負責建立、維護與銷毀原生 `WebSocket` 實例。
141
141
 
142
- | 行為 | 說明 |
143
- | --------------------------- | ---------------------------------------------------------------------------------------------------------------- |
144
- | mount + `autoConnect: true` | 自動 `connect()` |
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"`) |
147
- | 重連 | 非主動斷線且 `reconnectMs > 0` 時,以固定間隔重試(無 exponential backoff);`reconnectMax > 0` 時超過次數即停止 |
148
- | 重連前 | 若已有舊 socket,先關閉並 emit `close`(reason: `"reconnect"`) |
142
+ | 行為 | 說明 |
143
+ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
144
+ | `WsProvider` 載入且 `autoConnect: true` | 自動連線 |
145
+ | unmount | 取消重連(`reconnectAttempt` 與 `reconnectExhausted` 歸零)、停止探活、清空 outbound 佇列;store 同步為 `status: "closed"`、`phase: "idle"`;關閉 socket 並 emit `close`(reason: `"provider unmount"`) |
146
+ | `disconnect()` | 同 unmount 的 cleanup store 重置,但不觸發自動重連;emit `close`(reason: `"client disconnect"`) |
147
+ | 重連 | 非主動斷線且 `reconnectMs > 0` 時,以固定間隔重試(無 exponential backoff);`reconnectMax > 0` 時超過次數即停止 |
148
+ | `connect()` 時已有舊 socket | 先關閉舊 socket emit `close`(reason: `"reconnect"`),再建立新連線(手動 connect 或自動重連皆同) |
149
149
 
150
150
  ---
151
151
 
@@ -153,27 +153,29 @@ createWsContext(options)
153
153
 
154
154
  必須在對應的 `WsProvider` 內使用。回傳值以 `useMemo` 穩定引用,**不會**因 store 或訊息更新而重繪元件。
155
155
 
156
- | 方法 | 簽名 | 說明 |
157
- | ------------ | ---------------------------- | ------------------------------------------------------------------------------------------ |
158
- | `send` | `(data) => boolean` | 傳送原始資料(`string`、`ArrayBuffer`、`Blob` 等)。已 OPEN 則立即送出;否則視佇列設定入隊 |
159
- | `sendJson` | `(data: unknown) => boolean` | `JSON.stringify` 後呼叫 `send` |
160
- | `connect` | `() => void` | 建立連線;若已有連線會先關閉舊 socket |
156
+ | 方法 | 簽名 | 說明 |
157
+ | ------------ | ---------------------------- | ------------------------------------------------------------------------------------------------ |
158
+ | `send` | `(data) => boolean` | 傳送原始資料(`string`、`ArrayBuffer`、`Blob` 等)。已 OPEN 則立即送出;否則視佇列設定入隊 |
159
+ | `sendJson` | `(data: unknown) => boolean` | `JSON.stringify` 後呼叫 `send`;回傳值同 `send`,無法序列化時為 `false` |
160
+ | `connect` | `() => void` | 建立連線;若已有連線會先關閉舊 socket(見 `WsProvider`) |
161
161
  | `disconnect` | `() => void` | 主動斷線;store 設為 `phase: "idle"`、`status: "closed"`,**不**觸發自動重連;清空 outbound 佇列 |
162
- | `getStatus` | `() => WsStatus` | 讀取當下 status;不訂閱、不觸發渲染 |
162
+ | `getStatus` | `() => WsStatus` | 讀取當下 status;不訂閱、不觸發渲染 |
163
163
 
164
164
  **`send` / `sendJson` 回傳值:**
165
165
 
166
166
  - `true` — 已送出,或已成功入隊
167
- - `false` — OPEN 且佇列已滿(`outgoingQueueMax > 0` 且達上限),或佇列關閉(`outgoingQueueMax === 0`);`sendJson` 另含 `JSON.stringify` 失敗(如 circular reference)
167
+ - `false` — 未送出:佇列已滿、佇列關閉,或 `sendJson` 無法序列化
168
168
 
169
169
  ---
170
170
 
171
171
  ### `useWsStore()`
172
172
 
173
- 必須在對應的 `WsProvider` 內使用。底層以 `useSyncExternalStore` 訂閱外部 store
173
+ 必須在對應的 `WsProvider` 內使用。底層以 `useSyncExternalStore` 訂閱外部 store;**partial 內欄位值未變時不會通知訂閱者**(shallow compare)。
174
174
 
175
175
  `WsState` 定位:**連線健康/outbound 佇列/重連** 等低頻、連線生命週期資訊。高頻訊息請用 `useWsEvents("message", …)`,不要寫進 store。
176
176
 
177
+ **建議:** 以 selector 只訂閱需要的欄位(例如 `(s) => s.phase`)。不帶 selector 的 `useWsStore()` 會訂閱整份 state,任一欄位變更都會觸發 re-render。
178
+
177
179
  ```ts
178
180
  useWsStore(): WsState
179
181
  useWsStore<T>(selector: (state: WsState) => T): T
@@ -195,9 +197,9 @@ interface WsState {
195
197
  }
196
198
  ```
197
199
 
198
- | 適合放進 store | 不適合 |
199
- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
200
- | `status`、`phase`、重連進度(`reconnectAttempt` / `reconnectExhausted`)、待送佇列長度、探活/stall 等連線健康摘要 | `lastMessage`、訊息歷史、業務 payload |
200
+ | 適合放進 store | 不適合 |
201
+ | ---------------------------------------------------------------------------------------------------- | ------------------------------------- |
202
+ | `status`、`phase`、重連進度(`reconnectAttempt` / `reconnectExhausted`)、探活/stall 等連線健康摘要 | `lastMessage`、訊息歷史、業務 payload |
201
203
 
202
204
  `CreateWsContextOptions`(如 `url`、`reconnectMax`)在 `createWsContext` 時凍結,**不在** `WsState`;UI 若需顯示 `n/max` 請自行保存設定值,或訂閱時與 store 欄位組合。
203
205
 
@@ -214,17 +216,17 @@ interface WsState {
214
216
 
215
217
  #### `WsPhase`
216
218
 
217
- | 值 | 意義 |
218
- | --------------- | -------------------------------------------------------------------- |
219
- | `idle` | 未連線、未排程重連(初始或手動 `disconnect()`) |
220
- | `connecting` | 首次或手動 `connect()` 連線中 |
221
- | `open` | 已連線 |
222
- | `reconnecting` | 自動重連週期(等待計時器或連線中);搭配 `status`、`reconnectAttempt` |
223
- | `stopped` | 不會再自動重連;`reconnectExhausted` 區分達上限或未啟用重連 |
219
+ | 值 | 意義 |
220
+ | -------------- | --------------------------------------------------------------------- |
221
+ | `idle` | 未連線、未排程重連(初始或手動 `disconnect()`) |
222
+ | `connecting` | 首次或手動 `connect()` 連線中 |
223
+ | `open` | 已連線 |
224
+ | `reconnecting` | 自動重連週期(等待計時器或連線中);搭配 `status`、`reconnectAttempt` |
225
+ | `stopped` | 不會再自動重連;`reconnectExhausted` 區分達上限或未啟用重連 |
224
226
 
225
227
  `status` 與 `phase` 常同時變化,但語意不同。例如 `phase === "reconnecting"` 且 `status === "closed"` 表示正在等待重連計時器;`status === "connecting"` 則表示計時器已觸發、正在嘗試連線。
226
228
 
227
- **建議:** 以 selector 只訂閱需要的欄位;state 擴充後可避免不必要的重繪。
229
+ **範例:**
228
230
 
229
231
  ```tsx
230
232
  const phase = useWsStore((s) => s.phase);
@@ -255,19 +257,22 @@ const canDisconnect =
255
257
  - `handler` 以 ref 保存最新引用,callback 重建**不會**導致重新訂閱
256
258
  - `type` 變更**會**重新訂閱
257
259
  - 意外斷線時,`close` handler 觸發前 store 已更新為 `status: "closed"` 及對應 `phase`(`reconnecting` / `stopped` 等)
260
+ - 主動 `disconnect()` 或 Provider unmount 亦同:先更新 store,再 emit `close`
258
261
  - 需監聽多種事件時,分別呼叫多次 `useWsEvents`
259
262
 
260
263
  ---
261
264
 
262
- ### 探活:`LivenessOptions`
265
+ ### 探活
266
+
267
+ 透過 `createWsContext({ liveness: { … } })` 啟用。連線 OPEN 後週期性送出應用層 ping(經 `send` 送 JSON,非 WebSocket 控制帧);若在 `timeoutMs` 內未收到符合條件的 pong,主動 `close()` socket(進而觸發重連流程)。
263
268
 
264
- 透過 `createWsContext({ liveness: { … } })` 啟用。連線 OPEN 後開始週期性送 ping;若在 `timeoutMs` 內未收到符合條件的 pong,主動 `close()` socket(進而觸發重連流程)。
269
+ `liveness` 選項形狀如下:
265
270
 
266
271
  ```ts
267
272
  interface LivenessOptions {
268
273
  intervalMs: number; // ping 間隔(毫秒)
269
274
  timeoutMs: number; // 等待 pong 逾時(毫秒)
270
- ping: unknown | (() => unknown); // ping payload;函式則每次動態產生
275
+ ping: unknown | (() => unknown); // ping payload;函式則每次動態產生
271
276
  isPong: (data: unknown) => boolean; // 判定傳入 data 是否為 pong
272
277
  }
273
278
  ```
@@ -290,7 +295,7 @@ createWsContext({
290
295
  });
291
296
  ```
292
297
 
293
- 探活期間,`onmessage` 收到的每一筆資料都會先經 `isPong` 判定;若為 pong 則重置逾時計時,並照常 emit `"message"` 事件。
298
+ 探活期間,`onmessage` 收到的每一筆資料都會先經 `isPong` 判定;若為 pong 則清除逾時計時,並照常 emit `"message"` 事件。ping payload 一律以 `JSON.stringify` 送出(僅支援 JSON 格式)。
294
299
 
295
300
  ---
296
301
 
@@ -298,14 +303,14 @@ createWsContext({
298
303
 
299
304
  當 `outgoingQueueMax > 0` 時:
300
305
 
301
- | 時機 | 行為 |
302
- | ------------------------ | ------------------------------ |
303
- | `send` 且 socket 未 OPEN | 訊息入隊(FIFO) |
304
- | 佇列已滿 | 回傳 `false`,**不**丟棄舊訊息 |
305
- | socket OPEN | 依序 flush 全部佇列 |
306
- | `disconnect()` | 清空佇列 |
307
- | `WsProvider` unmount | 清空佇列 |
308
- | 自動重連等待期間 | **保留**佇列 |
306
+ | 時機 | 行為 |
307
+ | ------------------------ | --------------------------------------------------------- |
308
+ | `send` 且 socket 未 OPEN | 訊息入隊(FIFO) |
309
+ | 佇列已滿 | 回傳 `false`,**不**丟棄舊訊息 |
310
+ | socket OPEN | 依序 flush 全部佇列 |
311
+ | `disconnect()` | 清空佇列;store 設為 `idle` / `closed`(見 `WsProvider`) |
312
+ | `WsProvider` unmount | 清空佇列;store 同步(見 `WsProvider`) |
313
+ | 自動重連等待期間 | **保留**佇列 |
309
314
 
310
315
  ---
311
316
 
@@ -313,14 +318,15 @@ createWsContext({
313
318
 
314
319
  自 `react-ws-context` 主入口匯出:
315
320
 
316
- | 型別 | 說明 |
317
- | ------------------------ | -------------------------------------------------- |
318
- | `CreateWsContextOptions` | `createWsContext` 的選項 |
319
- | `WsContextValue` | `useWsActions()` 回傳型別 |
320
- | `WsEvents` | 事件名稱與 handler 的型別對應 |
321
- | `WsStatus` | WebSocket 連線狀態(`WsState` 的一環) |
321
+ | 型別 | 說明 |
322
+ | ------------------------ | --------------------------------------------------- |
323
+ | `CreateWsContextOptions` | `createWsContext` 的選項 |
324
+ | `LivenessOptions` | `createWsContext` 的 `liveness` 選項 |
325
+ | `WsContextValue` | `useWsActions()` 回傳型別 |
326
+ | `WsEvents` | 事件名稱與 handler 的型別對應 |
327
+ | `WsStatus` | WebSocket 連線狀態(`WsState` 的一環) |
322
328
  | `WsPhase` | Provider 連線意圖與重連策略階段(`WsState` 的一環) |
323
- | `WsState` | 可訂閱 store 的 state 形狀(連線健康/佇列/重連) |
329
+ | `WsState` | 可訂閱 store 的 state 形狀(連線健康/佇列/重連) |
324
330
 
325
331
  ---
326
332
 
@@ -340,15 +346,15 @@ import {
340
346
  } from "react-ws-context/stall";
341
347
  ```
342
348
 
343
- | 匯出 | 說明 |
344
- | ---------------------------- | --------------------------------------------------------------- |
345
- | `STALL_MESSAGE_TYPE` | 客戶端控制訊息 type 常數(`"STALL"`) |
346
- | `STALL_ACK_TYPE` | 伺服器確認 type 常數(`"STALL_ACK"`) |
347
- | `createStallMessage(action)` | 建立可 `sendJson` 的控制訊息 |
348
- | `parseStallMessage(data)` | 從 `useWsEvents("message")` 的 `data` 解析;格式不符回傳 `null` |
349
- | `StallAction` | `"stall" \| "release"` |
350
- | `StallMessage` | `{ type: "STALL"; action: StallAction }` |
351
- | `StallAck` | `{ type: "STALL_ACK"; action: StallAction; active: boolean }` |
349
+ | 匯出 | 說明 |
350
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------ |
351
+ | `STALL_MESSAGE_TYPE` | 客戶端控制訊息 type 常數(`"STALL"`) |
352
+ | `STALL_ACK_TYPE` | 伺服器確認 type 常數(`"STALL_ACK"`)— 僅供型別使用,無內建 parser |
353
+ | `createStallMessage(action)` | 建立可 `sendJson` 的客戶端 `STALL` 訊息 |
354
+ | `parseStallMessage(data)` | 從 `useWsEvents("message")` 的 `data` 解析客戶端 `STALL` 訊息;格式不符回傳 `null`(不含 `STALL_ACK`) |
355
+ | `StallAction` | `"stall" \| "release"` |
356
+ | `StallMessage` | `{ type: "STALL"; action: StallAction }` |
357
+ | `StallAck` | `{ type: "STALL_ACK"; action: StallAction; active: boolean }` — 伺服器 ack 請自行解析 |
352
358
 
353
359
  **範例:**
354
360
 
@@ -367,14 +373,15 @@ sendJson(createStallMessage("stall"));
367
373
 
368
374
  ## 設計取捨與限制
369
375
 
370
- | 項目 | 說明 |
371
- | -------------- | ------------------------------------------------------------------------------------------ |
372
- | 設定不可變 | `url`、`reconnectMs` 等建立後固定;需換 URL 請另建 context 或手動 `disconnect` + `connect` |
373
- | 重連策略 | 固定間隔,無 exponential backoff;`reconnectMax > 0` 可限制次數 |
374
- | SSR | 不在 server 建立 `WebSocket`;`connect()` 在 `window` 不存在時為 no-op |
375
- | 錯誤狀態 | 不設 `"error"` status;請監聽 `useWsEvents("error")` |
376
- | `WsState` 範圍 | 只含連線健康/佇列/重連;訊息與業務資料不走 store |
377
- | 訊息與渲染 | 只呼叫 `useWsActions` 的元件不會因 store 或 message 重繪 |
376
+ | 項目 | 說明 |
377
+ | -------------- | ------------------------------------------------------------------------ |
378
+ | 設定不可變 | `url`、`reconnectMs` 等建立後固定;要換 URL 請另建一組 `createWsContext` |
379
+ | 重連策略 | 固定間隔,無 exponential backoff;`reconnectMax > 0` 可限制次數 |
380
+ | SSR | 不在 server 建立 `WebSocket`;`connect()` 在 `window` 不存在時為 no-op |
381
+ | 錯誤狀態 | 不設 `"error"` status;請監聽 `useWsEvents("error")` |
382
+ | `WsState` 範圍 | 只含連線健康/佇列/重連;訊息與業務資料不走 store |
383
+ | 訊息與渲染 | 只呼叫 `useWsActions` 的元件不會因 store 或 message 重繪 |
384
+ | Store 更新 | 相同欄位值重複寫入不觸發訂閱;建議以 selector 訂閱 |
378
385
 
379
386
  ---
380
387
 
@@ -393,8 +400,8 @@ sendJson(createStallMessage("stall"));
393
400
  - **作者/維護:** [pmndrs](https://github.com/pmndrs)(Poimandres)
394
401
  - **授權:** [MIT](https://github.com/pmndrs/zustand/blob/main/LICENSE)
395
402
  - **借鑑範圍:**
396
- - 外部 store(`getState` / `setState` / `subscribe` / `getInitialState`)— 靈感與行為對齊 [`vanilla.ts`](https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts),非完整搬移(無 middleware、replace、initializer factory
397
- - React 訂閱 hook — 靈感來自 [`react.ts`](https://github.com/pmndrs/zustand/blob/main/src/react.ts) 的 `useStore`(selector 必填、無 `useDebugValue`)
403
+ - 外部 store(`getState` / `setState` / `subscribe` / `getInitialState`)— 靈感與行為對齊 [`vanilla.ts`](https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts),非完整搬移(無 middleware、replace、initializer factory);`setState` 另含 partial shallow dedup(值未變不通知)
404
+ - React 訂閱 hook — 靈感來自 [`react.ts`](https://github.com/pmndrs/zustand/blob/main/src/react.ts) 的 `useStore`(無 `useDebugValue`);公開 API 的可選 selector overload `createUseWsStore`
398
405
  - **對應原始碼:** `src/ws-context/store.ts`、`src/ws-context/use-store.ts`
399
406
 
400
407
  ### [nanoevents](https://github.com/ai/nanoevents)
@@ -403,5 +410,5 @@ sendJson(createStallMessage("stall"));
403
410
  - **授權:** [MIT](https://github.com/ai/nanoevents/blob/main/LICENSE)
404
411
  - **借鑑範圍:**
405
412
  - Typed event emitter — 執行期邏輯幾乎對齊 [`createNanoEvents`](https://github.com/ai/nanoevents/blob/main/index.js);型別為本套件收斂版
406
- - `useWsEventsApi` 為本套件自行新增(React `useState` 包裝,見 `ws-events.ts`)
413
+ - React 訂閱包裝 本套件自行新增(`ws-events.ts`)
407
414
  - **對應原始碼:** `src/ws-context/emitter.ts`、`src/ws-context/ws-events.ts`
package/dist/index.d.mts CHANGED
@@ -54,15 +54,15 @@ type WsState = {
54
54
  };
55
55
  //#endregion
56
56
  //#region src/ws-context/liveness/types.d.ts
57
- /** 探活選項 */
57
+ /** 探活設定 */
58
58
  interface LivenessOptions {
59
- /** 探活間隔(ms) */
59
+ /** ping 間隔(毫秒) */
60
60
  intervalMs: number;
61
- /** 探活超時(ms) */
61
+ /** 等待 pong 逾時(毫秒) */
62
62
  timeoutMs: number;
63
- /** 探活訊息 */
63
+ /** ping 內容;函式則每次動態產生 */
64
64
  ping: unknown | (() => unknown);
65
- /** 探活回應判定 */
65
+ /** 判定傳入資料是否為 pong */
66
66
  isPong: (data: unknown) => boolean;
67
67
  }
68
68
  //#endregion
@@ -74,77 +74,78 @@ interface CreateWsContextOptions {
74
74
  /** 連線協定 */
75
75
  protocols?: string | string[];
76
76
  /**
77
- * 是否自動重新連線。
77
+ * 是否在 WsProvider 載入時自動連線。
78
78
  *
79
79
  * @default true
80
80
  */
81
81
  autoConnect?: boolean;
82
82
  /**
83
- * 自動重連間隔(ms);`0` 不重連。
83
+ * 非主動斷線後,自動重連的間隔(毫秒)。
84
+ *
85
+ * `0` 表示不重連。
84
86
  *
85
87
  * @default 0
86
88
  */
87
89
  reconnectMs?: number;
88
90
  /**
89
- * 非主動斷線後最多自動重連幾次(不含首次 `autoConnect`)。
90
- * `reconnectAttempt` 於成功 `open`、手動 `connect()` 或 `disconnect()` 歸零。
91
+ * 非主動斷線後,最多自動重連幾次。
91
92
  *
92
- * `0` 不限制(只要 `reconnectMs > 0`)。
93
+ * `0` 表示不限制;需搭配 `reconnectMs > 0` 才會重連。
93
94
  *
94
95
  * @default 0
95
96
  */
96
97
  reconnectMax?: number;
97
98
  /**
98
- * 未連線時發送訊息的佇列上限;`0` 關閉。
99
+ * 未連線時,待送訊息的佇列上限。
100
+ *
101
+ * `0` 表示關閉佇列。
99
102
  *
100
103
  * @default 0
101
104
  */
102
105
  outgoingQueueMax?: number;
103
106
  /**
104
- * 資料解析函數。
107
+ * 將原始 `MessageEvent.data` 轉成業務資料。
105
108
  *
106
- * 預設處理方式:如果資料為字串,嘗試 `JSON.parse`,否則原樣返回。
109
+ * 預設:字串嘗試 `JSON.parse`,失敗則原樣回傳;非字串原樣回傳。
107
110
  */
108
111
  parse?: (data: MessageEvent["data"]) => unknown;
109
- /** 探活 */
112
+ /** 探活設定;省略則不啟用 */
110
113
  liveness?: LivenessOptions;
111
114
  }
112
115
  interface WsEvents {
113
116
  /**
114
117
  * 收到訊息
115
118
  *
116
- * @param data 訊息內容,已經過 `parse` 處理
117
- * @param event 原始事件物件
119
+ * @param data `parse` 處理後的資料
120
+ * @param event 原始 `MessageEvent`
118
121
  */
119
122
  message: (data: unknown, event: MessageEvent) => void;
120
123
  /** 連線建立 */
121
124
  open: (event: Event) => void;
122
- /** 發生錯誤 */
125
+ /** 連線錯誤 */
123
126
  error: (event: Event) => void;
124
- /** 連線斷開 */
127
+ /** 連線關閉 */
125
128
  close: (event: CloseEvent) => void;
126
129
  }
127
- /** 連線操作 API;可訂閱的連線層 state(健康/佇列/重連)請用 `useWsStore` */
130
+ /** 連線操作 API;連線層 state 請用 `useWsStore` 訂閱 */
128
131
  interface WsContextValue {
129
132
  /**
130
- * 傳送訊息
133
+ * 傳送原始資料。
131
134
  *
132
- * @param data 訊息內容
133
- * @returns 是否已送出或已入隊
135
+ * @returns `true` 表示已送出或已入隊;`false` 表示未送出
134
136
  */
135
137
  send: (data: Parameters<WebSocket["send"]>[0]) => boolean;
136
138
  /**
137
- * 傳送 JSON 資料
139
+ * JSON 傳送資料。
138
140
  *
139
- * @param data 資料
140
- * @returns 是否已送出或已入隊;`JSON.stringify` 失敗(如 circular reference)回傳 `false`
141
+ * @returns `send`;無法序列化時為 `false`
141
142
  */
142
143
  sendJson: (data: unknown) => boolean;
143
144
  /** 建立連線 */
144
145
  connect: () => void;
145
- /** 斷開連線 */
146
+ /** 主動斷開連線 */
146
147
  disconnect: () => void;
147
- /** 讀取當下 `status`;不訂閱 store、不觸發渲染 */
148
+ /** 讀取當下 `status`;不訂閱、不觸發渲染 */
148
149
  getStatus: () => WsStatus;
149
150
  }
150
151
  //#endregion
@@ -166,5 +167,5 @@ declare function createWsContext(options: CreateWsContextOptions): {
166
167
  useWsEvents: <E extends keyof WsEvents>(type: E, handler: WsEvents[E]) => void;
167
168
  };
168
169
  //#endregion
169
- export { type CreateWsContextOptions, type WsContextValue, type WsEvents, type WsPhase, type WsState, type WsStatus, createWsContext };
170
+ export { type CreateWsContextOptions, type LivenessOptions, type WsContextValue, type WsEvents, type WsPhase, type WsState, type WsStatus, createWsContext };
170
171
  //# 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;;;;;;;;;;;;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"}
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;;;;;;;;;;KAYA;;EAEV,QAAQ;;EAER,OAAO;;;;;;;;EAQP;;;;;;EAMA;;;;;UCxDe;;EAEf;;EAEA;;EAEA;;EAEA,SAAS;;;;;UCLM;;EAEf;;EAEA;;;;;;EAMA;;;;;;;;EAQA;;;;;;;;EAQA;;;;;;;;EAQA;;;;;;EAMA,SAAS,MAAM;;EAEf,WAAW;;UAGI;;;;;;;EAOf,UAAU,eAAe,OAAO;;EAEhC,OAAO,OAAO;;EAEd,QAAQ,OAAO;;EAEf,QAAQ,OAAO;;;UAIA;;;;;;EAMf,OAAO,MAAM,WAAW;;;;;;EAMxB,WAAW;;EAEX;;EAEA;;EAEA,iBAAiB;;;;;;;;;;;iBCxCH,gBAAgB,SAAS;EAmBL,eAAA,YAAA,sCAAiB,IAAA"}
package/dist/index.mjs CHANGED
@@ -177,7 +177,6 @@ function createStore(initialState) {
177
177
  }
178
178
  };
179
179
  }
180
- /** partial 內所有 key 值與 state 相同則視為無變更,不通知訂閱者 */
181
180
  function hasPartialChanged(state, partial) {
182
181
  for (const [key, value] of Object.entries(partial)) if (!Object.is(state[key], value)) return true;
183
182
  return false;
@@ -339,7 +338,13 @@ function createWsContext(options) {
339
338
  const outgoingQueue = useOutgoingQueue(outgoingQueueMax);
340
339
  const livenessSession = useLiveness(liveness, () => wsRef.current);
341
340
  const getStatus = useCallback(() => store.getState().status, [store]);
342
- /** 主動斷線與 Provider unmount 共用;`reason` 區分 `"client disconnect"` / `"provider unmount"` */
341
+ /**
342
+ * 主動斷線與 Provider unmount 共用 cleanup。
343
+ *
344
+ * 關閉 socket 時以 `reason` 寫入 synthetic `close` 事件:
345
+ * - `"client disconnect"` — `disconnect()`
346
+ * - `"provider unmount"` — `WsProvider` unmount
347
+ */
343
348
  const teardown = useCallback((reason) => {
344
349
  reconnect.cancel();
345
350
  livenessSession.stop();
@@ -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 依賴。\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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"}
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":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-ws-context",
3
- "version": "0.4.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",