In real-time systems like WebSocket-based client libraries, managing state effectively is crucial for ensuring smooth, predictable behavior. Our store implementation will provide a robust mechanism for managing internal state, handling actions, and supporting an event-driven design pattern.
To allow the library to be flexible and provide a good developer experience, we are also introducing the concept of middleware. This addition will allow developers to inject custom logic without modifying the core library internally.
State Management
At its core, the Store serves as the single source of truth for the WebSocket client’s state. This centralized approach ensures that all components of the client operate based on the same, consistent view of the state. This is really important.
Event-Driven Architecture
We will move the Event Bus out of the client and into the store to manage event-driven communication. This approach decouples components, making the system more modular and easier to maintain.
Through the event bus, components can:
- Subscribe to specific events.
- Emit events to notify listeners of state changes or other occurrences.
Basic API and Usage
The store is designed around the concept of creating Actions, which send Events to Handler functions that can mutate the State.
We set up the store using a defineStore function:
export function defineStore<S>(initialState: S): Store<S> {
let state = initialState;
const bus = defineEventBus();
// Map from eventName -> list of handler functions
const actionHandlers = new Map<string, Array<HandlerFn<S>>>();
/**
* Registers a handler for a specific event name
* e.g. store.defineAction("increment", (state, payload) => newState)
*/
function defineAction(eventName: string, handler: HandlerFn<S>) {
const existing = actionHandlers.get(eventName) ?? [];
existing.push(handler);
actionHandlers.set(eventName, existing);
}
function dispatch(eventName: string, payload?: unknown): Promise<Action> | Action {
// ...
}
function getState(): S {
return state;
}
return {
getState,
defineAction,
dispatch,
on: bus.on,
off: bus.off,
};
}
Using the Store
The Store is created using the defineStore function. This function initializes the state, sets up the event bus, and prepares the store for handling actions and state updates. Here’s an example:
const initialState = { count: 0 };
const store = defineStore(initialState);
This initializes a store with a count field set to 0.
Use the getState method to access the current state at any time:
const currentState = store.getState();
console.log(currentState);
This is particularly useful for querying real-time connection status, queued messages, or other key data in your application.
Actions and Handlers
Actions are the building blocks of the Store’s functionality. An Action represents an event, like a user connecting to the WebSocket server or receiving a message that triggers a state update or an external side effect.
Handlers define how specific actions modify the state. For example:
store.defineAction("increment", (state, payload) => {
return { ...state, count: state.count + 1 };
});
With this, any dispatch of the increment action increases the count field in the store’s state by 1.
Dispatching Actions
Actions are triggered using the dispatch method. This method ensures that actions are processed in the correct order and that all relevant handlers are invoked. For example:
store.dispatch("increment");
console.log(store.getState()); // { count: 1 }
The dispatch method also works seamlessly with asynchronous handlers, ensuring flexibility for real-world scenarios.
Integrating the Store in DurableWS
Real-Time Updates
The store integrates tightly with WebSocket events to reflect real-time updates. For example:
- Connection Events: The store handles actions like
connectedanddisconnectedto update connection status. - Incoming Messages: The
messageaction processes and stores incoming WebSocket messages.
export function client(config: WebSocketClientConfig): WebSocketClient {
let ws: WebSocket | null = null;
const initialState: ClientState = {
connected: false,
};
const store = defineStore<ClientState>(initialState);
function onConnecting(state: ClientState) {
return { ...state, connected: false };
}
function onConnected(state: ClientState) {
return { ...state, connected: true };
}
// ... onClose, onError, onMessage are handled similarly
store.defineAction("connecting", onConnecting);
store.defineAction("connected", onConnected);
store.defineAction("close", onClose);
store.defineAction("error", onError);
store.defineAction("message", onMessage);
const api = (store: Store<ClientState>): WebSocketClient => {
return {
connect() {
if (ws && ws.readyState !== WebSocket.CLOSED) {
return;
}
store.dispatch("connecting");
ws = new WebSocket(config.url);
ws.onopen = () => {
store.dispatch("connected");
};
ws.onclose = (closeEvent) => {
store.dispatch("close", closeEvent);
};
ws.onerror = (err) => {
console.log("onerror called");
store.dispatch("error", err);
};
ws.onmessage = (event) => {
// safeJSONParse is a simple helper function that doesn't throw errors
const message = safeJSONParse<unknown>(event.data);
store.dispatch("message", message);
};
},
close() {
ws?.close();
ws = null;
},
send(data: unknown) {
if (typeof data === "string") {
ws.send(data);
} else {
ws.send(JSON.stringify(data));
}
},
};
};
const clientApi = api(store);
return clientApi;
}
Here’s a summary of the changes from a previous version:
- The event bus is moved out of the client and into the store.
- The Store is integrated directly into the client.
- The client API is wrapped in a function that takes the store as an argument, which simplifies adding middleware.
- The
emitmethod is renamed tosendto match the native WebSocket API. - The message data type is checked to determine whether to send raw strings or JSON strings.
Middleware
Middleware is a powerful concept that allows developers to extend and customize the behavior of the store without altering its core functionality. In our WebSocket client, middleware enables you to insert additional logic during the dispatch process, such as logging, error handling, or asynchronous operations. This modular approach enhances flexibility and maintainability, making it easier to introduce new features or modify existing behaviors seamlessly.
Understanding the Middleware Pattern
The middleware pattern is widely used in various frameworks to create flexible, modular, and extensible applications. By allowing functions to intercept and process requests or actions, middleware facilitates the separation of concerns, enabling developers to compose complex behaviors from simple, reusable components.
Middleware in Popular Frameworks
Express.js
Express.js relies on middleware to handle HTTP requests and responses. Middleware functions have access to the request (req) and response (res) objects, as well as the next function, which passes control to the next middleware in the stack. This approach allows for tasks like:
- Authentication
- Parsing Request Bodies
- Logging Requests
Example:
const express = require("express");
const app = express();
// Middleware to log requests
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next();
});
// Middleware to parse JSON bodies
app.use(express.json());
app.get("/", (req, res) => {
res.send("Hello, World!");
});
app.listen(3000);
Hono
Hono is a lightweight web framework for Deno and Node.js that emphasizes performance and developer experience. Like Express, Hono uses middleware to handle various aspects of request processing, but with a more functional approach.
Example:
import { Hono } from "hono";
const app = new Hono();
// Middleware to add a custom header
app.use("*", async (c, next) => {
await next();
c.header("X-Custom-Header", "Hono");
});
app.get("/", (c) => c.text("Hello, Hono!"));
app.fire();
Integrating Middleware into the Store
Our store implementation includes built-in support for middleware. Here’s how we structure it:
- Middleware Definition: Middleware functions conform to a specific signature, accepting a
MiddlewareContextand anextfunction. TheMiddlewareContextprovides access to the current action and state, whilenextallows the middleware to pass control to the next function in the chain.
/**
* Represents an action to be dispatched in the store.
*/
export interface Action<T = Payload> {
/** The type of the action. */
type: string;
/** The payload of the action. */
payload?: T;
}
/**
* A function that updates the state in response to an event.
*/
export interface HandlerFn<S> {
(state: S, payload: unknown): S | void;
}
/**
* Function signature for the next middleware or final action.
*/
export type NextFn = () => Promise<Action> | Action;
/**
* Middleware function signature.
* @param context - The context for the middleware, including the store, current action, etc.
* @param next - The callback to proceed to the next middleware or final action.
*/
export type Middleware<S = unknown> = (context: MiddlewareContext<S>, next: NextFn) => Promise<Action> | Action;
/**
* Context information passed to each middleware.
*/
export interface MiddlewareContext<S = unknown> {
/** The current action being processed. */
action: Action;
/** The current state of the store. */
state: S;
/** Reference to the WebSocket client. */
client: WebSocketClient;
/** Configuration for the WebSocket client. */
config: WebSocketClientConfig;
}
Updates to the Store
-
Registering Middleware: The
usemethod allows developers to add one or more middleware functions to the store. These middleware functions are stored in an ordered list and executed sequentially during the dispatch process.function use(...mws: Middleware<S>[]) { middlewares.push(...mws); } -
Applying Middleware: When an action is dispatched, the store invokes the
applyMiddlewaresfunction, which recursively executes each middleware in the order they were registered. After all middleware have been processed, the action handlers are invoked to update the state and emit relevant events./** * The final step after all middlewares have run. * It updates the store state by calling each handler for the given eventName, * then emits the relevant events. */ function runHandlersAndEmit(eventName: string, payload?: unknown): Action { const handlers = actionHandlers.get(eventName) ?? []; let stateChanged = false; for (const handler of handlers) { try { const newState = handler(state, payload); if (newState !== undefined && newState !== state) { state = newState; stateChanged = true; } } catch (error) { console.error(`Handler for ${eventName} threw an error:`, error); throw error; } } bus.emit(eventName, payload); if (stateChanged) { bus.emit("state-changed", { type: eventName, state }); } return { type: eventName, payload }; } /** * Recursively call each middleware in order. If we’ve called all of them, * runHandlersAndEmit is invoked to do the actual state update and event emission. */ function applyMiddlewares(context: MiddlewareContext<S>): Promise<Action> | Action { let index = -1; return executeMiddleware(0); async function executeMiddleware(i: number): Promise<Action> { if (i <= index) { throw new Error("next() called multiple times"); } index = i; const middleware = middlewares[i]; if (!middleware) { // No more middleware, invoke the final handler const { type, payload } = context.action; return runHandlersAndEmit(type, payload); } // Merge storeWideContext with the per-dispatch fields: const mergedContext: MiddlewareContext<S> = { ...storeWideContext, ...context, }; // Call the current middleware with mergedContext return middleware(mergedContext, () => executeMiddleware(i + 1)); } }
Creating and Using Middleware
Below are examples demonstrating how to create and integrate middleware functions into your store.
1. Logging Middleware
const loggerMiddleware: Middleware = (context, next) => {
console.log("Dispatching action:", context.action);
const result = next();
console.log("New state:", context.state);
return result;
};
// Usage
store.use(loggerMiddleware);
2. Error Handling Middleware
const errorMiddleware: Middleware = (context, next) => {
try {
return next();
} catch (error) {
console.error("Error processing action:", context.action.type, error);
// Optionally, you can dispatch an error action or perform other error handling here
throw error; // Re-throw the error after logging
}
};
// Usage
store.use(errorMiddleware);
3. Asynchronous Middleware
const asyncMiddleware: Middleware = async (context, next) => {
if (context.action.type === "fetchData") {
try {
// Perform asynchronous operation
const data = await fetchDataFromAPI(context.action.payload);
// Optionally, dispatch another action with the fetched data
store.dispatch("dataFetched", data);
// Proceed to the next middleware or handler
return await next();
} catch (error) {
console.error("Failed to fetch data:", error);
// Optionally, dispatch an error action
store.dispatch("fetchError", error);
throw error;
}
}
// If the action type is not "fetchData", proceed normally
return await next();
};
async function fetchDataFromAPI(payload: any): Promise<any> {
// Replace with an actual API call
return new Promise((resolve) => {
setTimeout(() => resolve({ data: "Sample Data" }), 1000);
});
}
// Register the asyncMiddleware with the store
store.use(asyncMiddleware);
// Dispatch the "fetchData" action
store.dispatch("fetchData", {
/* payload data */
});
Remember that the middleware must await the next function in order for asynchronous logic to work correctly.
Combining Multiple Middleware
Middleware functions are executed in the order they are registered, allowing you to compose complex behaviors out of smaller, more focused middleware modules.
store.use(loggerMiddleware, errorMiddleware, asyncMiddleware);
Functional Composition in Middleware
Functional composition lets you chain middleware so each has a clear, single responsibility. The result is a highly modular and maintainable dispatch pipeline where each function does one job well.
Benefits of this approach
- Separation of Concerns: Keep cross-cutting concerns (logging, authentication, etc.) out of your core business logic.
- Reusability: Middleware functions can be easily shared across multiple projects or modules.
- Extensibility: Add or modify middleware as your application grows without altering existing, proven code paths.
- Enhanced Debugging: Logging and other diagnostic middleware make it easier to pinpoint issues.
By introducing a well-structured Store and supporting middleware, our WebSocket client can remain both powerful and flexible. The store’s event-driven design and action handling simplify state management, while middleware provides a straightforward pattern for extending functionality; whether it’s logging, error handling, or asynchronous operations. Together, these features offer a scalable foundation for building robust, real-time applications.