@mantlejs/socketio
Socket.IO transport adapter for Mantle JS. Bridges Mantle services to Socket.IO clients, enabling real-time bidirectional communication over WebSockets with the same hook pipeline used by REST adapters.
Installation
Section titled “Installation”npm install @mantlejs/socketio socket.ioConcepts
Section titled “Concepts”Transport adapter
Section titled “Transport adapter”socketio() is a Mantle plugin that registers a Socket.IO server alongside your existing HTTP transport. Incoming socket events are mapped to service method calls (find, get, create, update, patch, remove) and routed through the full hook pipeline — the same before, after, and error hooks run regardless of whether the call arrives over HTTP or WebSocket.
Real-time events
Section titled “Real-time events”When a service method mutates data (create, update, patch, remove), Mantle emits an internal service:event; @mantlejs/socketio listens for it and can re-emit it to clients as {path} {event} (e.g. "messages created"). Delivery is opt-in — it only reaches connections targeted by a channel publisher (see “Channels” below). Configuring the transport alone does not broadcast anything.
Provider
Section titled “Provider”Calls arriving via Socket.IO set params.provider = "socket.io" in HookContext. Hooks can inspect this to apply transport-specific behavior (e.g. skip authentication for internal calls).
Channels
Section titled “Channels”Channels are named sets of socket connections. They control which clients receive a real-time event when a service mutates data.
Broadcasting is opt-in: until you register a publisher on a service (or globally via app.publish()), no events are sent to any client. When a publisher is registered, the transport calls it after each mutating method (create, update, patch, remove) and broadcasts the result to every connection returned by the publisher.
Each socket gets a connection object — a plain Record<string, unknown> that persists for the lifetime of that socket. It is passed to channel publishers and available as params.connection inside hooks, making it a natural place to stash per-socket state (e.g. the authenticated user after login).
Quick start
Section titled “Quick start”import { mantle } from "@mantlejs/mantle";import { express } from "@mantlejs/express";import { socketio } from "@mantlejs/socketio";
const app = mantle().configure(express()).configure(socketio());
app.listen(3030);Client (browser or Node)
import { io } from "socket.io-client";
const socket = io("http://localhost:3030");
// Call a service methodsocket.emit("find", "messages", {}, (error: unknown, result: unknown) => { console.log(result);});
// Listen for real-time eventssocket.on("messages created", (message: unknown) => { console.log("New message:", message);});The
messages createdevent above only fires once a publisher is registered for themessagesservice — see Channels quick start below. Without one, mutations still happen but nothing is broadcast to any client.
Channels quick start
Section titled “Channels quick start”import { mantle } from "@mantlejs/mantle";import { express } from "@mantlejs/express";import { socketio } from "@mantlejs/socketio";
const app = mantle().configure(express()).configure(socketio());
// 1. On connection, join every socket to the "everyone" channelapp.on("connection", (connection) => { app.channel("everyone").join(connection);});
// 2. Broadcast all service events to the "everyone" channelapp.publish(() => app.channel("everyone"));
app.listen(3030);User-scoped channels — join each socket to a per-user channel and broadcast only to that user:
app.on("connection", (connection) => { // Store the user on the connection object so publishers can read it const userId = (connection["user"] as { id: number } | undefined)?.id; if (userId) { app.channel(`user/${userId}`).join(connection); }});
app.service("messages").publish((data, ctx) => { const userId = (ctx.params.user as { id: number } | undefined)?.id; return userId ? app.channel(`user/${userId}`) : null;});Filtered channels — send an event only to connections that pass a predicate:
app.service("messages").publish((data) => app.channel("everyone").filter( (_data, connection) => (connection["role"] as string) === "admin", ),);Multiple channels — return an array to broadcast to the union of all connections (each socket receives the event at most once):
app.service("notifications").publish((data, ctx) => [ app.channel("admins"), app.channel(`user/${(ctx.params.user as { id: number }).id}`),]);socketio(options?)
Section titled “socketio(options?)”Returns a MantlePlugin. Call via app.configure(socketio(options)). Must be configured after an HTTP transport (express(), koa(), or http()) — the plugin attaches to the Node server the transport emits via the "http:server" app event when listen() is called.
app.configure( socketio({ path: "/socket.io", timeout: 30000, cors: true, }),);SocketioOptions
Section titled “SocketioOptions”| Field | Type | Default | Description |
|---|---|---|---|
path |
string |
"/socket.io" |
URL path the Socket.IO server listens on |
timeout |
number |
20000 |
Ping timeout in ms before closing idle connections (engine.io’s built-in default; only overridden when set) |
cors |
boolean | CorsOptions |
disabled | CORS on the socket.io handshake. Set this whenever the client’s url is a different origin than the page — e.g. a frontend dev server and an API dev server on different ports. true reflects Origin and allows the CRUD verbs; pass a CorsOptions (same shape as @mantlejs/express’s cors option) to restrict it. Socket.IO attaches its own request listener ahead of the HTTP transport’s middleware, so the transport’s own cors option (express({ cors: true }) and friends) never runs for /socket.io/* — this is the only way to allow a cross-origin socket connection. |
serverOptions |
Partial<ServerOptions> |
— | Additional Socket.IO Server constructor options. An explicit serverOptions.cors overrides the cors option above. |
app.channel(name)
Section titled “app.channel(name)”Returns a MantleChannel for the given name. Accepts a single name or an array of names. Channels are created lazily — calling app.channel("foo") twice returns the same underlying channel.
// Single channelconst ch = app.channel("admins");
// Combined channel — deduplicates connections automaticallyconst ch = app.channel(["admins", "moderators"]);Requires socketio() to be configured. Throws GeneralError if called before the plugin is registered.
app.publish(publisher)
Section titled “app.publish(publisher)”Registers a global publisher — called for every service that does not have its own per-service publisher. Returns app for chaining.
app.publish((data, ctx) => { // ctx: { app, path, params } return app.channel("everyone");});Return null, undefined, or void to suppress broadcasting for a specific event.
app.service(path).publish(publisher)
Section titled “app.service(path).publish(publisher)”Registers a per-service publisher. Takes precedence over the global publisher for that service. Returns the service handle for chaining.
app.service("messages").publish((data, ctx) => { const userId = (ctx.params.user as { id: number } | undefined)?.id; return userId ? app.channel(`user/${userId}`) : null;});params.rooms — per-call broadcast targeting
Section titled “params.rooms — per-call broadcast targeting”A before hook (or an internal caller) can set params.rooms to a channel name or array of names. When set, the mutation’s event is broadcast only to those channels, bypassing any configured publisher. No publisher is required — rooms is its own opt-in.
app.service("messages").hooks({ before: { create: [ async (ctx) => { ctx.params.rooms = [`conversation/${(ctx.data as { conversationId: string }).conversationId}`]; return ctx; }, ], },});app.channel(name).join(connection) / .leave(connection)
Section titled “app.channel(name).join(connection) / .leave(connection)”Adds or removes a connection from the channel. Both methods return this for chaining. Joining a connection that is already in the channel is a no-op.
app.on("connection", (connection) => { app.channel("everyone").join(connection);});
app.on("disconnect", (connection) => { // Connections are removed from all channels automatically on disconnect — // you only need leave() for manual mid-session departures. app.channel("vip").leave(connection);});app.channel(name).filter(fn)
Section titled “app.channel(name).filter(fn)”Returns a new MantleChannel that wraps the original but only delivers events to connections for which fn(data, connection) returns true. Does not mutate the original channel.
app.service("alerts").publish(() => app.channel("everyone").filter( (data, connection) => (connection["role"] as string) !== "guest", ),);Filters compose: calling .filter() on an already-filtered channel ANDs the predicates.
connection / disconnect events
Section titled “connection / disconnect events”app.on("connection", (connection: Record<string, unknown>) => { // Fired when a new socket connects. `connection` is the per-socket state object. // This is where you join the socket to its initial channels.});
app.on("disconnect", (connection: Record<string, unknown>) => { // Fired when a socket disconnects. // The socket is already removed from all channels automatically.});The connection object is the same reference passed to publishers via the channel’s connections array, to filter predicates as the second argument, and to hooks as params.connection.
import type { SocketioOptions } from "@mantlejs/socketio";import type { MantleChannel, ChannelPublisher, PublishContext } from "@mantlejs/mantle";| Type | Description |
|---|---|
SocketioOptions |
Options passed to socketio() |
MantleChannel |
Named set of socket connections; supports join, leave, and filter |
ChannelPublisher<T> |
(data, ctx: PublishContext) => MantleChannel | MantleChannel[] | null | void |
PublishContext |
{ app, path, params } — context passed to every publisher call |
Development
Section titled “Development”npx nx build socketio # compilenpx nx test socketio # run testsnpx nx lint socketio # lintPublishing
Section titled “Publishing”Build before publishing:
npx nx build socketioFirst publish (scoped packages require --access public):
cd packages/socketionpm publish --access publicSubsequent releases — bump version in packages/socketio/package.json, then:
cd packages/socketionpm publish