diff --git a/.changeset/open-moles-hug.md b/.changeset/open-moles-hug.md new file mode 100644 index 0000000..517c132 --- /dev/null +++ b/.changeset/open-moles-hug.md @@ -0,0 +1,6 @@ +--- +"@tapsioss/react-client-socket-manager": minor +--- + +Add `shouldUseStob` optional property to the `SocketClientProvider` for using the `ClientSocketManagerStub` instead of `ClientSocketManager` for SSR and tests. + \ No newline at end of file diff --git a/.changeset/thick-lemons-say.md b/.changeset/thick-lemons-say.md new file mode 100644 index 0000000..79a6468 --- /dev/null +++ b/.changeset/thick-lemons-say.md @@ -0,0 +1,6 @@ +--- +"@tapsioss/client-socket-manager": minor +--- + +Add `ClientSocketManagerStub` class to the package for SSR and tests. + \ No newline at end of file diff --git a/packages/core/README.md b/packages/core/README.md index 06f6e48..ce734b0 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -190,6 +190,40 @@ dispose(): void; Disposes of the socket, manager, and engine, ensuring all connections are closed and cleaned up. + +## `ClientSocketManagerStub` + +The package also exports a stubbed version of the socket manager for use in **testing** or **server-side rendering (SSR)** environments: + +```ts +import { ClientSocketManagerStub } from '@tapsioss/client-socket-manager'; + +const stub = new ClientSocketManagerStub("mock://url", { + eventHandlers: { + onSocketConnection() { + console.log("Simulated connection"); + }, + }, +}); + +stub.connect(); // Triggers onSocketConnection +stub.emit("message", "noop"); // No-op +stub.dispose(); // Marks the client as disposed +``` + +### Why use the stub? + +- Prevents actual network communication in unit tests and SSR. +- Mimics the API surface of the real `ClientSocketManager`. +- Triggers configured event handlers like `onSocketConnection` and `onSocketDisconnection`. + +### Stub Behavior Summary + +- Methods like `emit`, `subscribe`, and `unsubscribe` are no-ops. +- `connect()` and `disconnect()` simulate connection lifecycle events. +- The `connected`, `disposed`, `id`, and other properties behave consistently with a mock socket. + + ## License This project is licensed under the terms of the [MIT license](https://github.com/Tap30/client-socket-manager/blob/main/packages/core/LICENSE). diff --git a/packages/core/src/ClientSocketManagerStub.ts b/packages/core/src/ClientSocketManagerStub.ts new file mode 100644 index 0000000..ff2de33 --- /dev/null +++ b/packages/core/src/ClientSocketManagerStub.ts @@ -0,0 +1,142 @@ +import type { + ClientSocketManagerListenerOptions, + ClientSocketManagerOptions, +} from "./types.ts"; + +/** + * A stub implementation of `ClientSocketManager` intended for use in + * test environments or server-side rendering (SSR), where actual socket + * connections are unnecessary or undesired. + * + * Provides no-op methods and tracks basic connection/disposal state. + */ +class ClientSocketManagerStub { + private _inputListeners: Partial; + + private _connected = false; + private _disposed = false; + + /** + * Indicates this is a mock/stub implementation. + */ + public static __mock__ = true; + + /** + * Creates a new stubbed ClientSocketManager. + * + * @param _uri - Optional URI string, ignored in stub. + * @param options - Optional configuration object containing event handlers. + */ + constructor(_uri?: string, options?: Partial) { + this._inputListeners = options?.eventHandlers ?? {}; + } + + /** + * A static session identifier. + * Returns a mock ID if connected, otherwise null. + */ + public get id(): string | null { + return this._connected ? "__id__" : null; + } + + /** + * Whether the stub is considered connected. + */ + public get connected(): boolean { + return this._connected; + } + + /** + * Whether the connection has been recovered after interruption. + * Always returns false in the stub. + */ + public get recovered(): boolean { + return false; + } + + /** + * Whether the client attempts reconnection automatically. + * Always returns false in the stub. + */ + public get autoReconnectable(): boolean { + return false; + } + + /** + * Whether this instance has been disposed. + */ + public get disposed(): boolean { + return this._disposed; + } + + /** + * Emits a message to the server. + * No-op in stub. + * + * @param _args - Event name and payload, ignored in stub. + */ + public emit(): void {} + + /** + * Subscribes to a socket channel. + * No-op in stub. + * + * @param _channel - Channel name. + * @param _cb - Callback function. + * @param _options - Optional configuration for signal and subscription completion. + */ + public subscribe( + _channel: string, + _cb: () => void, + _options?: { + onSubscriptionComplete?: (channel: string) => void; + signal?: AbortSignal; + }, + ): void {} + + /** + * Unsubscribes from a socket channel. + * No-op in stub. + * + * @param _channel - Channel name. + * @param _cb - Callback function to remove. + */ + public unsubscribe(_channel: string, _cb: () => void): void {} + + /** + * Simulates connecting to a socket. + * Triggers the `onSocketConnection` event handler if defined. + */ + public connect(): void { + this._connected = true; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + this._inputListeners.onSocketConnection?.call(this as any); + } + + /** + * Simulates disconnecting the socket. + * Triggers the `onSocketDisconnection` event handler if defined. + */ + public disconnect(): void { + this._connected = false; + + this._inputListeners.onSocketDisconnection?.call( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + this as any, + "io client disconnect", + ); + } + + /** + * Cleans up the instance by disconnecting and clearing handlers. + */ + public dispose(): void { + this.disconnect(); + + this._disposed = true; + this._inputListeners = {}; + } +} + +export default ClientSocketManagerStub; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d796b66..877d33c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ export { default as ClientSocketManager } from "./ClientSocketManager.ts"; +export { default as ClientSocketManagerStub } from "./ClientSocketManagerStub.ts"; export { ManagerReservedEvents, SocketReservedEvents } from "./constants.ts"; export type { ClientSocketManagerListenerOptions, diff --git a/packages/react/README.md b/packages/react/README.md index 52e60d4..6fec8a6 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -57,6 +57,7 @@ const App = () => { return ( { +vitest.mock("@tapsioss/client-socket-manager", async () => { + const actualImports = await vitest.importActual( + "@tapsioss/client-socket-manager", + ); + return { + ...actualImports, ClientSocketManager: vitest.fn( (uri: string, options?: Partial) => - new __MockClientSocketManager__(uri, options), + new ClientSocketManagerStub(uri, options), ), }; }); diff --git a/packages/react/src/SocketClientProvider.tsx b/packages/react/src/SocketClientProvider.tsx index f1eabd7..7ff6d06 100644 --- a/packages/react/src/SocketClientProvider.tsx +++ b/packages/react/src/SocketClientProvider.tsx @@ -1,4 +1,7 @@ -import { ClientSocketManager } from "@tapsioss/client-socket-manager"; +import { + ClientSocketManager as ClientSocketManagerOriginal, + ClientSocketManagerStub, +} from "@tapsioss/client-socket-manager"; import * as React from "react"; import { ConnectionStatus } from "./constants.ts"; import { SocketContext, type SocketContextValue } from "./Context.ts"; @@ -7,8 +10,35 @@ import type { SocketClientProviderProps, } from "./types"; -const __SINGLETON_REFS__: Record = {}; +const __SINGLETON_REFS__: Record< + string, + | InstanceType + | InstanceType + | null +> = {}; +/** + * React provider that initializes and manages a socket connection via `ClientSocketManager`. + * + * It optionally uses a stubbed socket manager in server-side rendering or testing environments. + * The provider ensures a singleton instance per URI and updates the connection status accordingly. + * + * Properties: + * - `children` (`React.ReactNode`): React children to render within the provider. + * - `uri` (`string`): The URI to connect the socket client to. + * - `shouldUseStob?` (`boolean`): Optional flag indicating whether to use the stubbed version of `ClientSocketManager` (useful for SSR or testing). + * - Additional props from `ClientSocketManagerOptions` can be provided, such as `eventHandlers`, `reconnectionDelay`, etc. + * + * @param props - Props for the provider, including connection URI, stub flag, and socket manager options. + * @returns A context provider that supplies the socket instance and its connection status. + * + * @example + * ```tsx + * + * + * + * ``` + */ const SocketClientProvider = (props: SocketClientProviderProps) => { const { children, uri, ...options } = props; @@ -19,37 +49,43 @@ const SocketClientProvider = (props: SocketClientProviderProps) => { const [connectionStatus, setConnectionStatus] = React.useState(ConnectionStatus.DISCONNECTED); + const registerClientSocketManager = ( + client: SocketContextValue["socket"], + ) => { + setClientInstance(client); + __SINGLETON_REFS__[uri] = client; + }; + React.useEffect(() => { if (!__SINGLETON_REFS__[uri]) { - const client = new ClientSocketManager(uri, { - ...options, - eventHandlers: { - ...(options.eventHandlers ?? {}), - onSocketConnection() { - options.eventHandlers?.onSocketConnection?.call(client); - - setConnectionStatus(ConnectionStatus.CONNECTED); - }, - onSocketDisconnection(reason, details) { - options.eventHandlers?.onSocketDisconnection?.call( - client, - reason, - details, - ); - - setConnectionStatus(ConnectionStatus.DISCONNECTED); + if (props.shouldUseStob) { + registerClientSocketManager(new ClientSocketManagerStub(uri, {})); + } else { + const client = new ClientSocketManagerOriginal(uri, { + ...options, + eventHandlers: { + ...(options.eventHandlers ?? {}), + onSocketConnection() { + options.eventHandlers?.onSocketConnection?.call(client); + setConnectionStatus(ConnectionStatus.CONNECTED); + }, + onSocketDisconnection(reason, details) { + options.eventHandlers?.onSocketDisconnection?.call( + client, + reason, + details, + ); + setConnectionStatus(ConnectionStatus.DISCONNECTED); + }, + onReconnecting(attempt) { + options.eventHandlers?.onReconnecting?.call(client, attempt); + setConnectionStatus(ConnectionStatus.RECONNECTING); + }, }, - onReconnecting(attempt) { - options.eventHandlers?.onReconnecting?.call(client, attempt); + }); - setConnectionStatus(ConnectionStatus.RECONNECTING); - }, - }, - }); - - setClientInstance(client); - - __SINGLETON_REFS__[uri] = client; + registerClientSocketManager(client); + } } else { throw new Error( [ diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 5746760..ba67266 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,20 +1,57 @@ import type { ClientSocketManager, ClientSocketManagerOptions, + ClientSocketManagerStub, } from "@tapsioss/client-socket-manager"; import type * as React from "react"; import type { ConnectionStatus } from "./constants.ts"; import type { SocketContextValue } from "./Context.ts"; +/** + * Type representing the union of all possible connection status values + * defined in `ConnectionStatus`. + */ type StatusType = typeof ConnectionStatus; +/** + * All valid connection state values for the socket client. + */ export type ConnectionStatusValues = StatusType[keyof StatusType]; +/** + * The default ClientSocketManager implementation used in production. + */ export type SocketInstance = ClientSocketManager; +/** + * A stubbed version of the ClientSocketManager, typically used in + * testing or server-side rendering (SSR) environments. + */ +export type SocketInstanceStub = ClientSocketManagerStub; + +/** + * The return type of the `useSocketClient` hook, + * derived from the context value shape. + */ export type SocketClientHookReturnType = SocketContextValue; +/** + * Props for the `SocketClientProvider` component. + */ export type SocketClientProviderProps = { + /** + * React children to render within the provider. + */ children: React.ReactNode; + + /** + * The URI to connect the socket client to. + */ uri: string; + + /** + * Optional flag indicating whether to use the stubbed version of + * ClientSocketManager. This is useful for SSR or tests. + */ + shouldUseStob?: boolean; } & Partial; diff --git a/packages/react/src/useSocketClient.test.tsx b/packages/react/src/useSocketClient.test.tsx index 101e6d7..a728d68 100644 --- a/packages/react/src/useSocketClient.test.tsx +++ b/packages/react/src/useSocketClient.test.tsx @@ -1,11 +1,7 @@ -import { - MockClientSocketManager as __MockClientSocketManager__, - act, - render, - screen, -} from "@repo/test-helpers"; +import { act, render, screen } from "@repo/test-helpers"; import { ClientSocketManager, + ClientSocketManagerStub, type ClientSocketManagerOptions, } from "@tapsioss/client-socket-manager"; import { @@ -22,11 +18,16 @@ import { ConnectionStatus } from "./constants.ts"; import useSocketClient from "./useSocketClient.ts"; // Mock the ClientSocketManager class -vitest.mock("@tapsioss/client-socket-manager", () => { +vitest.mock("@tapsioss/client-socket-manager", async () => { + const actualImports = await vitest.importActual( + "@tapsioss/client-socket-manager", + ); + return { + ...actualImports, ClientSocketManager: vitest.fn( (uri: string, options?: Partial) => - new __MockClientSocketManager__(uri, options), + new ClientSocketManagerStub(uri, options), ), }; }); diff --git a/packages/react/src/useSocketClient.ts b/packages/react/src/useSocketClient.ts index 02bb9af..7ff403f 100644 --- a/packages/react/src/useSocketClient.ts +++ b/packages/react/src/useSocketClient.ts @@ -2,6 +2,23 @@ import * as React from "react"; import { SocketContext } from "./Context.ts"; import type { SocketClientHookReturnType } from "./types.ts"; +/** + * A React hook that provides access to the current socket client instance and its connection status. + * + * This hook must be used within a ``, otherwise it will throw an error. + * + * @throws Will throw an error if used outside of a ``. + * + * @returns {SocketClientHookReturnType} An object containing the socket client instance and its connection status. + * + * @example + * ```tsx + * const { socket, connectionStatus } = useSocketClient(); + * if (socket?.connected) { + * socket.emit("message", { text: "Hello" }); + * } + * ``` + */ const useSocketClient = (): SocketClientHookReturnType => { const ctx = React.useContext(SocketContext); diff --git a/test-helpers/src/index.ts b/test-helpers/src/index.ts index 2b7e4de..4e50670 100644 --- a/test-helpers/src/index.ts +++ b/test-helpers/src/index.ts @@ -1,4 +1,3 @@ export { act, render, screen } from "@testing-library/react"; -export { default as MockClientSocketManager } from "./mocks/ClientSocketManager.ts"; export { default as createPromiseResolvers } from "./promise-resolvers.ts"; export * from "./server.ts"; diff --git a/test-helpers/src/mocks/ClientSocketManager.ts b/test-helpers/src/mocks/ClientSocketManager.ts deleted file mode 100644 index b1fb06d..0000000 --- a/test-helpers/src/mocks/ClientSocketManager.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { - ClientSocketManagerListenerOptions, - ClientSocketManagerOptions, -} from "@tapsioss/client-socket-manager"; - -class ClientSocketManager { - private _inputListeners: Partial; - private _connected = false; - private _disposed = false; - - public static __mock__ = true; - - constructor(_uri?: string, options?: Partial) { - this._inputListeners = options?.eventHandlers ?? {}; - } - - public get id(): string | null { - return this._connected ? "__id__" : null; - } - - public get connected(): boolean { - return this._connected; - } - - public get recovered(): boolean { - return false; - } - - public get autoReconnectable(): boolean { - return false; - } - - public get disposed() { - return this._disposed; - } - - public emit() {} - - public subscribe( - _channel: string, - _cb: () => void, - _options?: { - onSubscriptionComplete?: (channel: string) => void; - signal?: AbortSignal; - }, - ): void {} - - public unsubscribe(_channel: string, _cb: () => void): void {} - - public connect(): void { - this._connected = true; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - this._inputListeners.onSocketConnection?.call(this as any); - } - - public disconnect(): void { - this._connected = false; - - this._inputListeners.onSocketDisconnection?.call( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - this as any, - "io client disconnect", - ); - } - - public dispose(): void { - this.disconnect(); - - this._disposed = true; - this._inputListeners = {}; - } -} - -export default ClientSocketManager;