@desolint/socket-client
v0.0.1
Published
React Socket.IO client provider and hooks for Desol Int. projects
Readme
@desolint/socket-client
React provider and hooks for Socket.IO, with a non-React disconnect hatch.
Requirements
- React 18 or newer
- npm 7 or newer — npm 7+ installs peer dependencies automatically
Install
npm install @desolint/socket-clientreact and socket.io-client are peer dependencies and npm installs them for you
on npm 7+. @desolint/socket-shared is a pinned regular dependency and comes down
automatically — you never install it yourself.
yarn add @desolint/socket-client react socket.io-client
# or
pnpm add @desolint/socket-client react socket.io-clientWhy these are peer dependencies, not regular ones
Two copies of React break hooks outright — React keeps hook state in module-level
internals, so a component rendered by one copy using a hook from another throws
Invalid hook call. This package ships a provider and hooks, so it must use your
React.
socket.io-client holds the live connection. A second copy would open a second
socket to the same server, so events delivered to one would never reach components
listening through the other.
Declaring them as peers means npm reuses the copies your application already has.
Quick start
@desolint/socket-shared is a pinned dependency of this package, not a peer,
and its types and values (SocketContract, AUTH_FAILED_MESSAGE,
isAuthFailure, etc.) are re-exported here — @desolint/socket-client is the
only import specifier you need.
Mount the provider, passing your app's auth state:
'use client';
export default function SocketGate({children}) {
// enabled:false — read the auth query CACHE, never drive it. See below.
const {isLoggedIn} = useGetLoggedInUser({enabled: false});
return <SocketProvider shouldConnect={isLoggedIn}>{children}</SocketProvider>;
}That wrapper is the only socket code your app owns. It exists because the provider needs your auth state and the package must not fetch it itself.
url defaults to NEXT_PUBLIC_SERVER_URL, which every Desol frontend already
sets, and falls back to the page's own origin. Pass url only to override it.
Consume events — subscribe/unsubscribe is handled for you, and an inline handler won't cause a resubscribe on every render:
useSocketEvent<AppContract, 'message'>('message', (text) => {
showToast({type: 'info', message: text});
});Send events with the same typed socket useSocket returns — emitting has
no subscribe/unsubscribe lifecycle, so it needs no dedicated hook. socket is
null until shouldConnect opens a connection, hence the optional chaining:
const {socket} = useSocket<AppContract>();
socket?.emit('helloWorld', 'Hello from client');Ask for a response instead of firing and forgetting, via socket.io's own
emitWithAck — still no dedicated hook, same reasoning as why emitting itself
needs none:
const result = await socket?.emitWithAck('chat:send', {recipientId, text});emitWithAck is only typed for events whose contract signature ends in a
callback — an event without one won't offer it, which is exactly the
compile-time signal for whether an event supports acks at all.
Disconnect from outside React (a 401 interceptor, a resetAppState util):
import {disconnectSocket} from '@desolint/socket-client';
disconnectSocket(); // no-op when no provider is mountedDesign notes
Two things this package deliberately does not do
It does not fetch auth. shouldConnect is a prop your app controls. A
provider that drove its own auth query previously caused an infinite reload
loop: an unguarded /users/me on a public route → 401 → interceptor calls
resetAppState() → window.location.href → remount → refetch. Keeping auth
ownership in the app makes this impossible.
It does not force a transport. Native browser WebSocket has no API for
custom headers, so extraHeaders only reaches the server over an HTTP
request — polling, or the initial handshake before upgrading. A project
authenticating with a custom header, not a cookie, must not force
transports: ['websocket'].
Cookie auth is unaffected either way: browsers attach cookies to a WebSocket
handshake automatically, by the cookie's own Domain/SameSite/Secure
rules, not by transport. What cookie auth does need is withCredentials:
true — already set in DEFAULT_CLIENT_OPTIONS — so the initial cross-site
request carries the cookie at all.
Behaviour
shouldConnectis the only thing that opens a connection. There's noconnect()on the context — two owners of one connection meant a manually opened socket the provider's effect would never tear down.- Connects when
shouldConnectturns true; disconnects when it turns false. - Disconnects on unmount.
- Stops reconnecting after a
connect_errorcarryingAUTH_FAILED_MESSAGE. Other errors still retry — the default is infinite reconnection, so without this, an expired cookie would hammer the server forever. - Guards SSR: connecting is a no-op when
windowis undefined. - Stopping reconnection is silent to your app. To react yourself — e.g. show
"session expired" — check a
connect_error'serroragainst the re-exportedisAuthFailureon thesocketuseSocket()returns, and clean up the listener the same wayuseSocketEventdoes. - The contract type (
useSocket<AppContract>(),useSocketEvent<AppContract, E>()) is applied per call, not enforced bySocketProvider— nothing stops one component specifying a different contract than another against the same provider.
API
| Export | Notes |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SocketProvider | Props:children, shouldConnect, and optionally url, options, socketClient. |
| useSocket<T>() | {socket, isConnected, disconnect}. Throws outside a provider. socket.emit(...) sends client→server events, typed from the contract's clientToServer map. |
| useSocketEvent<T, E>(event, handler) | Subscribes for the component's lifetime. |
| disconnectSocket() | Non-React hatch. Disconnects every mounted provider; no-op when none is mounted. |
| DEFAULT_CLIENT_OPTIONS | Whatoptions is merged over. |
disconnect exists so logout can drop the socket before the cookie is cleared,
without waiting for shouldConnect to flip.
socketClient is a test seam — pass a stub factory to drive the provider
without a server.
Development
npm install # install dependencies (from the repo root)
npm run build # build all three packages
npm test # type-check + jest
npm run lint # eslintThis package is part of the package-socket-io workspaces monorepo — run the
commands from the repository root, not this directory.
License
MIT © Desolint — see LICENSE.
Free to use, modify and redistribute, commercially or otherwise. Provided "as is", without warranty or liability of any kind.
