Sockets
Warlock ships a Socket.IO connector. When src/config/socket.ts exists, the framework starts a Socket.IO server next to your HTTP server, parks the instance in the DI container, and hands you a one-liner to grab it from any controller, service, or use-case. From there you’re using vanilla socket.io — rooms, namespaces, middleware, all of it.
That’s the honest scope today. The framework ships only the server side. The framework owns the lifecycle (boot, watch the config for changes, shut down cleanly). What you build on top — auth handshakes, per-user rooms, broadcast helpers — is plain Socket.IO code in your modules. The client side is plain socket.io-client (see Connecting a client). We’ll show the patterns that fit cleanly; deeper integrations (per-socket auth resolution, a typed event bus, automatic room joining for logged-in users) are roadmapped, not shipped.
socket.io is an optional peer. The framework does not depend on it directly — the connector lazy-imports it on boot. If you add src/config/socket.ts without socket.io installed, the connector throws on startup with install instructions. Run warlock add socket to install the dependency and eject a starter config.
When you reach for it
Section titled “When you reach for it”The HTTP side of Warlock covers request/response. Sockets cover the cases HTTP can’t:
- Push to the client. Order status changes, new chat messages, agent progress updates.
- Subscribe to live data. Dashboard widgets, presence indicators, collaborative editing.
- Low-latency two-way. Anything where a poll loop would be silly.
If your need is “the user clicks a button and gets a reply,” HTTP is fine. If your need is “the server has something new to say without being asked,” that’s socket territory.
The 30-second look
Section titled “The 30-second look”import type { SocketOptions } from "@warlock.js/core";
export default { options: { cors: { origin: "*", }, },} as SocketOptions;import { getSocketServer } from "@warlock.js/core";
const io = getSocketServer();
if (io) { io.on("connection", (socket) => { socket.on("join-order-room", (orderId: string) => { socket.join(`order:${orderId}`); }); });}import { getSocketServer } from "@warlock.js/core";
export async function notifyStatusChange(orderId: string, status: string) { const io = getSocketServer();
if (!io) { return; }
io.to(`order:${orderId}`).emit("status-changed", { status });}Three pieces — config file, listener registration in a module’s main.ts, and getSocketServer() for emitting from anywhere. That’s the whole surface today.
Configuration — src/config/socket.ts
Section titled “Configuration — src/config/socket.ts”The presence of the config file is the switch. No src/config/socket.ts, no Socket.IO server. Add the file, restart the dev server, and you’re live.
import type { SocketOptions } from "@warlock.js/core";
export default { port: 3001, options: { cors: { origin: "*", credentials: true, }, transports: ["websocket", "polling"], },} as SocketOptions;| Field | Type | When you need it |
|---|---|---|
port | number | Only when HTTP is disabled. With HTTP on, Socket.IO attaches to the same server. |
options | Socket.IO ServerOptions | Anything the Socket.IO docs lists — cors, path, pingInterval, transports, maxHttpBufferSize, etc. |
Editor autocomplete quirk. The exported
SocketOptions.optionsfield is currently typed as Node’shttpServerOptions(a source-side type quirk), so your editor won’t autocomplete Socket.IO option names — but they DO take effect at runtime, since the value is passed straight intonew Server(server, options).
The port story matters. If your project has src/config/http.ts (which most do), the socket connector reuses the Fastify HTTP server — no separate port, no second listener. If HTTP is disabled, the connector spins up its own raw HTTP server on the config’s port. SSL follows the same path: with HTTP enabled, you inherit its SSL setup; without HTTP, the connector creates an HTTPS server instead of an HTTP one when an ssl flag is truthy on the config.
sslis read but not yet on the type. The connector readssocketConfig.sslat runtime to decide HTTP vs HTTPS, but the exportedSocketOptionstype only declaresport?andoptions?. Settingssl: trueagainstas SocketOptionsis a type error today. If you need the standalone HTTPS path, drop theas SocketOptionsannotation (or cast throughas any) until the field lands on the type. With HTTP enabled this never matters — SSL is inherited from the Fastify server.
Editing the config tears the server down — but does not hot-apply new options. The socket connector watches src/config/socket.ts. On a change, the dev server calls the connector’s restart(), which runs shutdown() then start(). Here’s the catch: the Socket.IO server is built in boot(), and restart() never calls boot(). So saving the config disconnects open sockets and shuts the old server down, but a fresh server with your new options is not stood back up until a full dev-server restart. This is a current limitation — restart the dev server to apply config changes.
Registering listeners — main.ts
Section titled “Registering listeners — main.ts”The right place to wire io.on("connection", …) is your module’s main.ts. The framework auto-loads main.ts once per module at boot, after connectors are up. The socket instance is ready by then.
import { getSocketServer } from "@warlock.js/core";
const io = getSocketServer();
if (!io) { // Socket config is disabled in this environment — no-op. return;}
io.on("connection", (socket) => { console.log(`Socket connected: ${socket.id}`);
socket.on("join-conversation", (conversationId: string) => { socket.join(`conversation:${conversationId}`); });
socket.on("leave-conversation", (conversationId: string) => { socket.leave(`conversation:${conversationId}`); });
socket.on("disconnect", () => { console.log(`Socket disconnected: ${socket.id}`); });});The null check matters — sockets are opt-in, so getSocketServer() returns null when there’s no config. Tests, prod environments without realtime, and the early bootstrap phase all see null. Guard once at the top of main.ts and you can use io without !-asserting it.
getSocketServer() — the lookup
Section titled “getSocketServer() — the lookup”Inside any service, controller, or use-case, grab the singleton:
import { getSocketServer } from "@warlock.js/core";
const io = getSocketServer(); // Server | nullThe return type is the Socket.IO Server from the socket.io package. Everything that library does, you have. Rooms, namespaces, middleware:
const io = getSocketServer();
if (!io) { return;}
// Emit to one socketio.to(socketId).emit("event-name", payload);
// Emit to a roomio.to("admins").emit("alert", { message: "All hands" });
// Broadcast to everyoneio.emit("server-shutdown", { at: Date.now() });
// Get a namespaceconst adminIo = io.of("/admin");
// Apply middlewareio.use((socket, next) => { const token = socket.handshake.auth.token; // …verify token, attach user, call next() or next(new Error(...)) next();});The container guarantees you get the same instance on every call — getSocketServer() is just a container.get("socket") underneath.
Connecting a client
Section titled “Connecting a client”The framework ships only the server. There is no bundled client — connect from the browser (or a Node service, or React Native) with the standard socket.io-client package, which you install in the client project yourself.
import { io } from "socket.io-client";
const socket = io("http://localhost:3000", { // The handshake auth payload your server reads via socket.handshake.auth auth: { token: localStorage.getItem("token"), },});
socket.on("connect", () => { console.log("connected", socket.id); socket.emit("join-conversation", "abc123");});
socket.on("status-changed", (payload) => { console.log("status changed", payload);});The auth object is what surfaces server-side as socket.handshake.auth — the same value an io.use((socket, next) => …) middleware reads to authenticate the connection (see the per-user rooms pattern below). Everything else — events, rooms, reconnection — is plain Socket.IO; the client docs apply unchanged.
Raw-server access (advanced). When the connector builds the Socket.IO server it also stores the underlying Node HTTP/HTTPS server under the container key socket.rawServer. You rarely need it — getSocketServer() covers normal use — but it’s there if you must reach the raw listener (for example to attach a second protocol to the same port):
import { container } from "@warlock.js/core";
if (container.has("socket.rawServer")) { const rawServer = container.get("socket.rawServer"); // rawServer is the Node http.Server / https.Server backing Socket.IO}Note: socket.rawServer is a runtime-only container key — it is not declared on the typed ContainerTypes map, so container.get("socket.rawServer") resolves through the generic string overload.
Common patterns
Section titled “Common patterns”Emit from a service after a Cascade save
Section titled “Emit from a service after a Cascade save”import { getSocketServer } from "@warlock.js/core";import { Order } from "../models/order";
export async function updateOrderStatus(orderId: string, status: string) { const order = await Order.find(orderId);
if (!order) { return null; }
order.set("status", status); await order.save();
const io = getSocketServer();
if (io) { io.to(`order:${orderId}`).emit("order-status-changed", { orderId, status, updatedAt: new Date(), }); }
return order;}The service does the database work first, then notifies. If the save fails, no event fires — the client never sees stale state.
Per-user rooms after auth
Section titled “Per-user rooms after auth”A common pattern: the moment a socket authenticates, join it to a personal room so other code can io.to("user:123").emit(...) without tracking socket IDs.
import { getSocketServer } from "@warlock.js/core";import { verifyJWT } from "app/auth/services/verify-jwt";
const io = getSocketServer();
if (!io) { return;}
io.on("connection", async (socket) => { const token = socket.handshake.auth.token;
if (!token) { socket.disconnect(); return; }
try { const user = await verifyJWT(token); socket.join(`user:${user.id}`); socket.data.user = user; } catch { socket.disconnect(); }});Note: this is plain Socket.IO. The framework doesn’t currently wire JWT verification into the socket handshake automatically — you write the middleware. If you want it pulled into a connector option later, that’s a roadmap item, not a current feature.
Broadcast from a use-case after hook
Section titled “Broadcast from a use-case after hook”import { getSocketServer, useCase } from "@warlock.js/core";
export const placeOrder = useCase<Order, PlaceOrderInput>({ name: "place_order", schema: placeOrderSchema, handler: async (input, ctx) => { return placeOrderService(input); }, after: [ async (order, ctx) => { const io = getSocketServer();
if (io) { io.to(`user:${ctx.userId}`).emit("order-placed", { orderId: order.id }); } }, ],});The after hook is fire-and-forget — broadcast failures don’t fail the use-case. That’s usually what you want for notifications.
Gotchas
Section titled “Gotchas”- No socket without config.
getSocketServer()returnsnullifsrc/config/socket.tsdoesn’t exist. Always null-check before emitting; don’t litter!non-null assertions across services. - Editing the socket config needs a full restart. Saving
src/config/socket.tsruns the connector’srestart()(=shutdown()+start()), which tears the running server down. Because the server is built inboot()— andrestart()never callsboot()— a fresh server with your new options does not come back up on its own. Restart the dev server to pick up config changes. (Open sockets disconnect on the teardown either way.) - Listener registration leaks. Adding
io.on("connection", …)at the top of a hot-reloaded file means each save piles on another listener. Always register inmain.ts(which is auto-loaded once per boot, not per module re-import) — or guard withonCleanupto remove the listener on reload. - The connector is
Latephase. It boots after app code has loaded, so trying to grabgetSocketServer()at module top-level — outsidemain.tsor a function body — gets younull. Wrap socket access in functions called at request time or inmain.ts. - Auth is on you. Socket.IO has no notion of “logged-in user” out of the box. The framework’s auth helpers run on HTTP requests. Wire a
io.use(middleware)that reads the handshake token if you need per-socket user resolution.
Going further
Section titled “Going further”- Socket.IO Server API — the surface you’re working with under
getSocketServer(). Rooms, namespaces, middleware, adapters. guides/bootstrap-and-connectors.md— connector lifecycle,EarlyvsLatephase, and how the socket connector fits.guides/warlock-config.md— the project-level config layer (sockets live insrc/config/socket.ts, not inwarlock.config.ts).