react-ws-context 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-08-28
11
+
12
+ ### Added
13
+
14
+ - `createWsContext(options)` factory — returns `WsProvider`, `useWsActions`, `useWsStore`, and `useWsEvents` per connection
15
+ - **Render isolation:** actions (no re-renders), connection-layer store (`useSyncExternalStore`), and message events kept separate
16
+ - Connection lifecycle: `connect`, `disconnect`, `autoConnect`, fixed-interval reconnect (`reconnectMs`)
17
+ - Outbound message queue while socket is not `OPEN` (`outgoingQueueMax`)
18
+ - Optional liveness / heartbeat (`LivenessOptions`: ping interval, timeout, custom `isPong`)
19
+ - Configurable `parse` for incoming messages (default: `JSON.parse` for strings)
20
+ - `WsStatus`: `idle` | `connecting` | `open` | `closed`
21
+ - `react-ws-context/stall` subpath — optional stall-control message helpers for demos and mock servers
22
+ - Zero runtime dependencies (`react >= 18` peer only)
23
+ - Smoke tests for context, liveness, and stall helpers
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GaiaYang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,356 @@
1
+ # react-ws-context
2
+
3
+ > **繁體中文:** [README.zh-TW.md](./README.zh-TW.md)
4
+
5
+ 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.
6
+
7
+ > **Maintainer:** [GaiaYang](https://github.com/GaiaYang)
8
+ > **Source:** [github.com/GaiaYang/react-ws](https://github.com/GaiaYang/react-ws) (package path: `packages/react-ws`)
9
+
10
+ ## Features
11
+
12
+ - **Zero runtime dependencies** — only `react >= 18` as a peer dependency
13
+ - **Frozen config** — `url`, `reconnectMs`, etc. are fixed at `createWsContext`; use `connect` / `disconnect` at runtime
14
+ - **Render isolation** — connection-layer state (health / queue / reconnect) lives in an external store; messages go through an event emitter, not React Context
15
+ - **Optional liveness** — periodic ping / pong; closes the socket on timeout to trigger reconnect
16
+ - **Optional outbound queue** — buffers messages while not OPEN, flushes on connect
17
+
18
+ ## Requirements
19
+
20
+ | Item | Version |
21
+ | ----------- | ------------------------------------------------- |
22
+ | React | >= 18 (`useSyncExternalStore`) |
23
+ | Environment | Browser Client Component (native `WebSocket` API) |
24
+
25
+ The package entry is marked `"use client"`. Modules that call `createWsContext` and components that use its hooks must live inside a Client boundary.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pnpm add react-ws-context react
31
+ # npm install react-ws-context react
32
+ # yarn add react-ws-context react
33
+ ```
34
+
35
+ ## Quick start
36
+
37
+ **1. Create a connection context (usually once, in its own module)**
38
+
39
+ ```tsx
40
+ "use client";
41
+
42
+ import { createWsContext } from "react-ws-context";
43
+
44
+ export const { WsProvider, useWsActions, useWsStore, useWsEvents } =
45
+ createWsContext({
46
+ url: "ws://localhost:8080",
47
+ reconnectMs: 2000,
48
+ });
49
+ ```
50
+
51
+ **2. Use it in your app**
52
+
53
+ ```tsx
54
+ "use client";
55
+
56
+ import { WsProvider, useWsActions, useWsStore, useWsEvents } from "./ws";
57
+
58
+ export function App({ children }: { children: React.ReactNode }) {
59
+ return <WsProvider>{children}</WsProvider>;
60
+ }
61
+
62
+ function Chat() {
63
+ const { sendJson } = useWsActions();
64
+ const status = useWsStore((s) => s.status);
65
+
66
+ useWsEvents("message", (data) => {
67
+ console.log("message", data);
68
+ });
69
+
70
+ return (
71
+ <button
72
+ disabled={status !== "open"}
73
+ onClick={() => sendJson({ type: "ping" })}
74
+ >
75
+ Send ({status})
76
+ </button>
77
+ );
78
+ }
79
+ ```
80
+
81
+ ## Core concepts
82
+
83
+ ```
84
+ createWsContext(options)
85
+
86
+ ├── WsProvider WebSocket instance, reconnect, liveness, outbound queue
87
+ ├── useWsActions() send / connect / disconnect — no re-renders
88
+ ├── useWsStore() connection-layer state: health / queue / reconnect
89
+ └── useWsEvents() open / message / error / close
90
+ ```
91
+
92
+ - **Call `createWsContext` multiple times** for independent connections (e.g. app WS + notification WS).
93
+ - **`WsState` holds low-frequency connection data only** — health (`status`), outbound queue (e.g. future `pendingCount`), reconnect (e.g. future `reconnectAttempt`). **Not** message payloads or app data.
94
+ - **Messages and errors** — use `useWsEvents`; keep message history in your own state, cache, or store.
95
+ - **Connection errors are not a `WsStatus`** — use `useWsEvents("error")`; native `error` is usually followed by `close`.
96
+
97
+ ---
98
+
99
+ ## API reference
100
+
101
+ ### `createWsContext(options)`
102
+
103
+ Creates a `WsProvider` and hooks bound to the same connection config.
104
+
105
+ #### `CreateWsContextOptions`
106
+
107
+ | Field | Type | Default | Description |
108
+ | ------------------ | ----------------------------------------- | ---------- | ---------------------------------------------------------------------- |
109
+ | `url` | `string` | (required) | WebSocket URL |
110
+ | `protocols` | `string \| string[]` | — | Passed to `new WebSocket(url, protocols)` |
111
+ | `autoConnect` | `boolean` | `true` | Call `connect()` after `WsProvider` mounts |
112
+ | `reconnectMs` | `number` | `0` | Reconnect delay (ms) after unintentional close; `0` disables reconnect |
113
+ | `outgoingQueueMax` | `number` | `0` | Max outbound queue size while not OPEN; `0` disables the queue |
114
+ | `parse` | `(data: MessageEvent["data"]) => unknown` | see below | Transform raw `MessageEvent.data` |
115
+ | `liveness` | `LivenessOptions` | — | Liveness / heartbeat config; omit to disable |
116
+
117
+ **Default `parse`:**
118
+
119
+ - string → try `JSON.parse`, return raw string on failure
120
+ - otherwise → return as-is
121
+
122
+ #### Returns
123
+
124
+ | Name | Type | Description |
125
+ | -------------- | ------------------------------------ | -------------------------------------------- |
126
+ | `WsProvider` | `React.FC<{ children }>` | Wraps the subtree that needs this connection |
127
+ | `useWsActions` | `() => WsContextValue` | Connection actions |
128
+ | `useWsStore` | `() => WsState` or `(selector) => T` | Subscribe to connection-layer state |
129
+ | `useWsEvents` | `(type, handler) => void` | Subscribe to WebSocket events |
130
+
131
+ ---
132
+
133
+ ### `WsProvider`
134
+
135
+ Creates, owns, and tears down the native `WebSocket`.
136
+
137
+ | Behavior | Description |
138
+ | --------------------------- | -------------------------------------------------------------------------------------------- |
139
+ | mount + `autoConnect: true` | Calls `connect()` |
140
+ | unmount | Closes connection, stops liveness, clears outbound queue, emits `close` |
141
+ | reconnect | Fixed interval when `reconnectMs > 0` and close was not intentional (no exponential backoff) |
142
+ | before reconnect | Closes existing socket and emits `close` (reason: `"reconnect"`) |
143
+
144
+ ---
145
+
146
+ ### `useWsActions(): WsContextValue`
147
+
148
+ Must be used inside the matching `WsProvider`. Return value is memoized and **does not** re-render on store or message updates.
149
+
150
+ | Method | Signature | Description |
151
+ | ------------ | ---------------------------- | ---------------------------------------------------------------------------- |
152
+ | `send` | `(data) => boolean` | Send raw data. Sends immediately when OPEN; otherwise enqueues if configured |
153
+ | `sendJson` | `(data: unknown) => boolean` | `JSON.stringify` then `send` |
154
+ | `connect` | `() => void` | Open connection; closes any existing socket first |
155
+ | `disconnect` | `() => void` | Intentional close; no auto-reconnect; clears outbound queue |
156
+ | `getStatus` | `() => WsStatus` | Read current status; no subscription, no re-render |
157
+
158
+ **`send` / `sendJson` return value:**
159
+
160
+ - `true` — sent or enqueued
161
+ - `false` — not OPEN and queue full (`outgoingQueueMax > 0`), or queue disabled (`outgoingQueueMax === 0`)
162
+
163
+ ---
164
+
165
+ ### `useWsStore()`
166
+
167
+ Must be used inside the matching `WsProvider`. Uses `useSyncExternalStore` under the hood.
168
+
169
+ `WsState` is for **connection health / outbound queue / reconnect** — low-frequency lifecycle data. For high-frequency messages, use `useWsEvents("message", …)`, not the store.
170
+
171
+ ```ts
172
+ useWsStore(): WsState
173
+ useWsStore<T>(selector: (state: WsState) => T): T
174
+ ```
175
+
176
+ #### `WsState`
177
+
178
+ ```ts
179
+ interface WsState {
180
+ status: WsStatus;
181
+ // Possible future fields (all low-frequency, connection-layer):
182
+ // reconnectAttempt?: number;
183
+ // pendingCount?: number;
184
+ }
185
+ ```
186
+
187
+ | Belongs in store | Does not belong |
188
+ | ------------------------------------------------------------------------- | -------------------------------------------- |
189
+ | `status`, reconnect count, pending queue size, liveness / stall summaries | `lastMessage`, message history, app payloads |
190
+
191
+ #### `WsStatus`
192
+
193
+ | Value | Meaning |
194
+ | ------------ | ------------- |
195
+ | `idle` | Not connected |
196
+ | `connecting` | Connecting |
197
+ | `open` | Connected |
198
+ | `closed` | Disconnected |
199
+
200
+ **Tip:** use a selector to subscribe to only the fields you need.
201
+
202
+ ```tsx
203
+ const status = useWsStore((s) => s.status);
204
+ ```
205
+
206
+ ---
207
+
208
+ ### `useWsEvents(type, handler)`
209
+
210
+ Must be used inside the matching `WsProvider`. Registers in `useEffect` and unsubscribes on unmount.
211
+
212
+ | `type` | Handler | Description |
213
+ | ----------- | ---------------------------------------------- | ---------------------------- |
214
+ | `"message"` | `(data: unknown, event: MessageEvent) => void` | `data` is the parsed payload |
215
+ | `"open"` | `(event: Event) => void` | Connection open |
216
+ | `"error"` | `(event: Event) => void` | Connection error |
217
+ | `"close"` | `(event: CloseEvent) => void` | Connection closed |
218
+
219
+ **Details:**
220
+
221
+ - Handler is kept in a ref — changing the callback does **not** re-subscribe
222
+ - Changing `type` **does** re-subscribe
223
+ - For multiple events, call `useWsEvents` multiple times
224
+
225
+ ---
226
+
227
+ ### Liveness: `LivenessOptions`
228
+
229
+ Enable via `createWsContext({ liveness: { … } })`. After OPEN, sends periodic pings; if no matching pong within `timeoutMs`, closes the socket (which can trigger reconnect).
230
+
231
+ ```ts
232
+ interface LivenessOptions {
233
+ intervalMs: number;
234
+ timeoutMs: number;
235
+ ping: unknown | (() => unknown);
236
+ isPong: (data: unknown) => boolean;
237
+ }
238
+ ```
239
+
240
+ **Example:**
241
+
242
+ ```tsx
243
+ createWsContext({
244
+ url: "ws://localhost:8080",
245
+ reconnectMs: 3000,
246
+ liveness: {
247
+ intervalMs: 30_000,
248
+ timeoutMs: 10_000,
249
+ ping: { type: "ping" },
250
+ isPong: (data) =>
251
+ typeof data === "object" &&
252
+ data != null &&
253
+ (data as { type?: string }).type === "pong",
254
+ },
255
+ });
256
+ ```
257
+
258
+ Every incoming message is checked with `isPong`; a pong resets the timeout timer and still emits `"message"`.
259
+
260
+ ---
261
+
262
+ ### Outbound queue
263
+
264
+ When `outgoingQueueMax > 0`:
265
+
266
+ | When | Behavior |
267
+ | -------------------------- | ------------------------------------------------- |
268
+ | `send` while not OPEN | Enqueue (FIFO) |
269
+ | Queue full | Returns `false`; does **not** drop older messages |
270
+ | Socket OPEN | Flush entire queue in order |
271
+ | `disconnect()` | Clear queue |
272
+ | `WsProvider` unmount | Clear queue |
273
+ | Waiting for auto-reconnect | **Keep** queue |
274
+
275
+ ---
276
+
277
+ ### Exported types
278
+
279
+ From the main `react-ws-context` entry:
280
+
281
+ | Type | Description |
282
+ | ------------------------ | ----------------------------------------------------- |
283
+ | `CreateWsContextOptions` | Options for `createWsContext` |
284
+ | `WsContextValue` | Return type of `useWsActions()` |
285
+ | `WsEvents` | Event name → handler map |
286
+ | `WsStatus` | Connection lifecycle status |
287
+ | `WsState` | Subscribable store shape (health / queue / reconnect) |
288
+
289
+ ---
290
+
291
+ ## Submodule: `react-ws-context/stall`
292
+
293
+ Optional stall-control message helpers for demos or mock-server integration. **Not** wired into `createWsContext` — parse in your own handlers.
294
+
295
+ ```ts
296
+ import {
297
+ STALL_MESSAGE_TYPE,
298
+ STALL_ACK_TYPE,
299
+ createStallMessage,
300
+ parseStallMessage,
301
+ type StallAction,
302
+ type StallMessage,
303
+ type StallAck,
304
+ } from "react-ws-context/stall";
305
+ ```
306
+
307
+ | Export | Description |
308
+ | ---------------------------- | ------------------------------------------------------------- |
309
+ | `STALL_MESSAGE_TYPE` | Client control message type (`"STALL"`) |
310
+ | `STALL_ACK_TYPE` | Server ack type (`"STALL_ACK"`) |
311
+ | `createStallMessage(action)` | Build a message for `sendJson` |
312
+ | `parseStallMessage(data)` | Parse from `useWsEvents("message")` data; `null` if invalid |
313
+ | `StallAction` | `"stall" \| "release"` |
314
+ | `StallMessage` | `{ type: "STALL"; action: StallAction }` |
315
+ | `StallAck` | `{ type: "STALL_ACK"; action: StallAction; active: boolean }` |
316
+
317
+ ---
318
+
319
+ ## Design trade-offs
320
+
321
+ | Topic | Notes |
322
+ | ---------------- | ------------------------------------------------------------------------------ |
323
+ | Immutable config | `url`, `reconnectMs`, etc. are fixed at create time |
324
+ | Reconnect | Fixed interval only; no exponential backoff or max retries yet |
325
+ | SSR | No `WebSocket` on the server; `connect()` is a no-op without `window` |
326
+ | Error status | No `"error"` in `WsStatus`; use `useWsEvents("error")` |
327
+ | `WsState` scope | Health / queue / reconnect only — not messages or app data |
328
+ | Rendering | Components that only call `useWsActions` do not re-render on store or messages |
329
+
330
+ ---
331
+
332
+ ## License
333
+
334
+ [MIT License](./LICENSE). Copyright (c) 2026 [GaiaYang](https://github.com/GaiaYang).
335
+
336
+ ---
337
+
338
+ ## Acknowledgments
339
+
340
+ This package does **not** list zustand or nanoevents as npm dependencies. It inlines minimal subsets for zero runtime deps. Source files include attribution headers.
341
+
342
+ ### [zustand](https://github.com/pmndrs/zustand)
343
+
344
+ - **Maintainer:** [pmndrs](https://github.com/pmndrs) (Poimandres)
345
+ - **License:** [MIT](https://github.com/pmndrs/zustand/blob/main/LICENSE)
346
+ - **Adapted from:**
347
+ - External store API — aligned with [`vanilla.ts`](https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts) (subset only)
348
+ - React subscription — inspired by [`react.ts`](https://github.com/pmndrs/zustand/blob/main/src/react.ts) `useStore`
349
+ - **Files:** `src/ws-context/store.ts`, `src/ws-context/use-store.ts`
350
+
351
+ ### [nanoevents](https://github.com/ai/nanoevents)
352
+
353
+ - **Author:** [Andrey Sitnik](https://github.com/ai) (`ai`)
354
+ - **License:** [MIT](https://github.com/ai/nanoevents/blob/main/LICENSE)
355
+ - **Adapted from:** [`createNanoEvents`](https://github.com/ai/nanoevents/blob/main/index.js); `useEmitter` added by this package
356
+ - **File:** `src/ws-context/emitter.ts`