Realtime Sockets
Netpress ships a Laravel-inspired realtime layer on top of [Socket.IO][socket-io]. You declare socket events with Route.socket(...) the same way you declare HTTP routes, group them under shared middleware, and broadcast to rooms through a small Socket facade.
The realtime stack is opt-in — installing socket.io and registering the provider is the only setup you need.
[socket-io]: https://socket.io/
At a Glance
// routes/sockets.js
import { Route } from "@admicaa/netpress";
import ChatSocketController from "../app/Sockets/ChatSocketController.js";
import OrderSocketController from "../app/Sockets/OrderSocketController.js";
Route.socket("chat:message", ChatSocketController.send);
Route.socketGroup({ middleware: ["auth:socket"] }, () => {
Route.socket("order:subscribe", "OrderSocketController@subscribe");
Route.socket("notifications:read", "NotificationSocketController@read");
});
// app/Sockets/ChatSocketController.js
import { Socket, log } from "@admicaa/netpress";
export default class ChatSocketController {
static async send(socket, payload, context) {
log.info("chat message", { from: context.user?.id, payload });
Socket.broadcast(payload.room, "chat:message", {
from: context.user?.id ?? null,
text: payload.text,
});
return { ok: true };
}
}
// somewhere in your code
import { Socket } from "@admicaa/netpress";
await Socket.broadcast("orders.123", "order:updated", order);
await Socket.emitTo(user.id, "notification:new", { title: "Hi" });
Installation
Add socket.io as a peer dependency:
npm install socket.io
Register SocketServiceProvider in bootstrap/providers.js. Pass the HTTP server when you have it on hand — the provider will create the socket.io server and attach it for you. If you prefer to keep the server creation in your own bootstrap, leave httpServer undefined and call Socket.attach(server) yourself when the listener starts.
// bootstrap/providers.js
import { SocketServiceProvider } from "@admicaa/netpress";
export default [
// ... your other providers
SocketServiceProvider,
];
// server.js
import "dotenv/config";
import { createServer } from "node:http";
import { config, log, Socket } from "@admicaa/netpress";
import { createApp } from "./bootstrap/app.js";
import "./routes/sockets.js"; // side-effect import: registers Route.socket(...)
const app = await createApp();
const server = createServer(app);
Socket.attach(server);
server.listen(config.get("app.port"), () => {
log.success(`Netpress is running on port ${config.get("app.port")}`);
});
Configuration
Create config/sockets.js to override the defaults:
export default {
enabled: true,
driver: "socket.io",
path: "/socket.io",
cors: { origin: "*" },
auth: {
guard: "api", // which auth guard backs the socket connection
required: true, // reject anonymous handshakes by default
},
rooms: {
user: "users.{id}", // template used to auto-join authenticated sockets
},
notifications: {
newEvent: "notification:new",
readEvent: "notification:read",
readAllEvent: "notification:read-all",
},
};
If you load configs through setSocketsConfig() directly:
import { setSocketsConfig } from "@admicaa/netpress";
import socketsConfig from "../../config/sockets.js";
setSocketsConfig(socketsConfig);
Route.socket and Route.socketGroup
Route.socket(event, handler, options?) registers a handler for a single event name. Handlers can be:
``js Route.useSocketController("ChatController", ChatSocketController); Route.socket("chat:message", "ChatController@send"); ``
- a function:
Route.socket("ping", (socket) => socket.emit("pong")); - a
"Controller@method"string after registering the controller: - a static class method:
Route.socket("chat:message", ChatSocketController.send) - any object with a
handle()method.
Route.socketGroup(options, callback) shares middleware, prefixes, and authentication options with every route registered inside the callback. Groups nest:
Route.socketGroup({ prefix: "orders", middleware: ["auth:socket"] }, () => {
Route.socket("subscribe", OrderSocketController.subscribe);
Route.socketGroup({ prefix: "admin" }, () => {
Route.socket("retry", OrderSocketController.retry); // → orders:admin:retry
});
});
auth:socket is a sentinel that says "this route requires whatever auth guard is configured under sockets.auth.guard". You can also use auth:api to pin a specific guard.
Controllers
Controllers receive four arguments:
| Arg | What it is |
|---|---|
socket | The connected socket.io socket instance |
payload | Whatever the client emitted with the event |
context | { user, guard, rooms, handshake, headers } — request metadata |
io | The underlying socket.io Server (use Socket for most broadcasts) |
export default class NotificationSocketController {
async read(socket, payload, context) {
if (!context.user) {
throw new SocketException("Sign in to read notifications", {
code: "unauthenticated",
status: 401,
});
}
return context.user.markNotificationAsRead(payload.id);
}
}
Whatever the handler returns is automatically passed to the client's ack callback as { ok: true, data }. Throwing a SocketException ships { ok: false, error, message } instead — the socket itself is never torn down.
You can scaffold a controller stub:
npm run artisan -- make:socket-controller ChatSocketController --method send --event chat:message
Middleware Pipeline
Two layers are available:
for auth, rate limiting, header parsing. ```js import { Socket } from "@admicaa/netpress";
- Connection middleware — runs once per socket handshake. Use it
Socket.use(async (socket, next) => { socket.handshake.headers["x-trace-id"] ||= crypto.randomUUID(); next(); }); ```
as the route handler but with a next callback: ``js Route.socketGroup({ middleware: [ async ({ payload, next }) => { if (!payload?.room) { throw new SocketException("room is required", { code: "bad_input" }); } await next(); }, ], }, () => { Route.socket("orders:subscribe", OrderSocketController.subscribe); }); ``
- Per-event middleware — runs once per event dispatch. Same shape
Both layers run in declaration order; per-event middleware sees the fully-built context (including the authenticated user).
Authentication
Netpress sockets reuse the same auth guards your HTTP routes do. The flow is intentionally Laravel-shaped:
your guard accepts).
configured guard to resolve a user, and stamps the user onto the socket.
that require authentication when no user was resolved.
- The client opens the socket with a token (API token, JWT, whatever
- The framework's connection middleware extracts the token, asks the
- The user is auto-joined to their personal room (
users.{id}). - Per-event middleware (
auth:socket) refuses dispatch on routes
One-Line Setup
// bootstrap (e.g. routes/sockets.js)
import { Route, Socket } from "@admicaa/netpress";
Socket.use("auth:socket"); // installs the bundled connection auth middleware
Route.socket("ping", () => "pong"); // public
Route.socketGroup({ middleware: ["auth:socket"] }, () => {
Route.socket("notifications:read", "NotificationSocketController@read");
Route.socket("orders:subscribe", "OrderSocketController@subscribe");
});
Socket.use("auth:socket") is a shorthand for Socket.use(createSocketAuthMiddleware({ guard, required })), with guard and required pulled from your sockets config.
Configuration
// config/sockets.js
export default {
auth: {
guard: "api", // any registered NetPress auth guard
required: true, // reject anonymous handshakes (default)
},
rooms: {
user: "users.{id}", // room template auto-joined for the logged-in user
},
};
required: true is the default. If you actually want a public socket server, opt out explicitly with required: false — the framework will still resolve the user when a token is present, but won't refuse the handshake.
Sending the Token from the Frontend
The token can travel through three transports. Pick one — auth is strongly preferred because it never lands in URLs or proxy logs.
Preferred — auth payload (io({ auth: { token } })):
import { io } from "socket.io-client";
const socket = io("http://localhost:3000", {
auth: { token: localStorage.getItem("apiToken") },
});
socket.on("connect", () => console.log("connected as", socket.id));
socket.on("connect_error", (err) => {
if (err?.message?.includes("Unauthenticated")) {
// Token rejected — kick the user back to /login
}
});
Fallback — Authorization header (extraHeaders):
const socket = io("http://localhost:3000", {
extraHeaders: { Authorization: `Bearer ${token}` },
});
Fallback only — query string (io({ query: { token } })):
const socket = io("http://localhost:3000", {
query: { token }, // tokens in URLs end up in proxy logs — avoid in production
});
Server-side, the framework reads them in this order:
socket.handshake.auth.tokensocket.handshake.query.tokensocket.handshake.headers.authorization(Bearer <token>)
Protected vs Public Routes
// routes/sockets.js
import { Route, Socket } from "@admicaa/netpress";
Socket.use("auth:socket");
// Public — no auth requirement, anyone can hit it.
Route.socket("ping", () => ({ pong: true }));
// Protected — anonymous sockets get { ok: false, error: "unauthenticated" }.
Route.socketGroup({ middleware: ["auth:socket"] }, () => {
Route.socket("orders:subscribe", "OrderSocketController@subscribe");
Route.socket("notifications:read", "NotificationSocketController@read");
});
If auth.required is true (the default) the *handshake itself* is refused for sockets without a valid token, so even the public ping route is unreachable to anonymous clients. To allow truly public sockets while still authenticating valid tokens, set auth.required: false and rely on the per-route auth:socket middleware for anything sensitive.
Accessing the Authenticated User
Every handler receives a context argument with the resolved user attached:
export default class NotificationSocketController {
async read(socket, payload, context) {
// context.user is the authenticated NetPress model — same shape as
// req.user inside HTTP controllers.
return context.user.markNotificationAsRead(payload.id);
}
}
For convenience, the same data is also available on socket.handshake through the framework's auth state, but context.user is the recommended API.
Per-User Room Restriction
Authenticated sockets are auto-joined to their users.{id} room. To prevent eavesdropping, socket.joinRoom(room) refuses to add a socket to *another* user's room:
await socket.joinRoom("users.42"); // OK only if context.user.id === 42
await socket.joinRoom("orders.123"); // always allowed (not a user-room)
Anything that doesn't match the rooms.user template falls outside the guard — bring your own authorization logic for tenant rooms, group rooms, etc.
Security Notes
failures both run through a token scrubber (scrubLogContext, scrubTokenString) before they reach the logger. JWTs, Bearer … headers, and token=… substrings are masked.
also scrubbed — even if a guard accidentally embeds the token in its own error message, the client only sees ***REDACTED***.
replay protection all live inside the auth guard. The socket layer intentionally does not reinvent that — use the same guard you would for HTTP.
every event dispatch when the route is registered under auth:socket, so revoking a session mid-connection takes effect on the next event without needing the client to reconnect.
the database channel of a notification, run them through the bundled sanitizePayload() helper first — see Notifications → Safety Notes.
- Token never logged. Connection-middleware errors and dispatch
- Token never echoed to clients.
SocketExceptionenvelopes are - Guard owns expiration. Token expiry, signature validation, and
- Per-event re-check. The connection-level user is re-checked at
- Sanitize before storing. If your handler echoes payloads into
Common Mistakes
proxy logs, browser history, and Referer headers. Use auth or the Authorization header in production.
group only enforces auth at *event* time. Without the connection middleware nothing actually resolves a user from the token, so even protected routes will see context.user === null.
but a buggy frontend that asks for someone else's room will get a visible forbidden_room error — fix the client to subscribe only to its own rooms.
bad token will still get past the handshake. If a route really is public, leave required: true and just don't add auth:socket to it.
ownership from context.user.id, never from a value the client sent.
- Putting the token in the URL. Tokens in
query.tokenend up in - Skipping
Socket.use("auth:socket"). Addingauth:socketto a - Joining
users.{otherId}directly. The room guard catches this, - Disabling
auth.requiredfor a "public" feature. Anyone with a - Trusting
payload.userIdfrom the client. Always derive
Rooms and Channels
Sockets get small helpers attached when they connect:
await socket.joinRoom("orders.123");
await socket.leaveRoom("orders.123");
The framework tracks joined rooms on the socket so other parts of the system can introspect membership without dipping into socket.io internals. As noted under Authentication → Per-User Room Restriction, per-user rooms are restricted to their owner.
Broadcast helpers live on the Socket facade:
Socket.broadcast("orders.123", "order:updated", order);
Socket.emitTo(user.id, "notification:new", payload); // users.42
Socket.emitTo("teams.eng", "deploy", payload); // arbitrary room
Socket.to("orders.123").emit("order:updated", order); // socket.io chain
Socket.emit("server:announcement", { msg: "We're back" }); // global
Socket.io() returns the underlying socket.io Server when you need escape hatches.
Error Handling
catches every error, logs it through the framework logger, and ships an envelope through the client's ack callback (or emits an error:socket event if no ack was provided).
client-visible error type — its fields go straight into the ack envelope.
{ error: "socket_error", message: "An unexpected error occurred." } so internal details never leak.
matching socket.io conventions; the connection is refused.
- Handler exceptions never tear down the socket. The dispatcher
SocketException(message, { code, status, data })is the- All other exceptions are reduced to a generic
- Connection-middleware failures are reported through
next(error),
Testing
The package ships a fake-friendly SocketManager:
import { setSocketManager, SocketManager } from "@admicaa/netpress";
const manager = new SocketManager();
setSocketManager(manager);
// then attach a fake io with .on/.use/.to/.emit and call manager.attach(io)
A working FakeIo + FakeSocket pair is in packages/netpress/tests/Unit/Sockets/SocketsTest.js if you want to copy it into your own test suite.
Frontend Example
<script type="module">
import { io } from "https://cdn.socket.io/4.8.0/socket.io.esm.min.js";
const socket = io("/", {
path: "/socket.io",
auth: { token: localStorage.getItem("apiToken") },
});
socket.on("connect", () => console.log("connected", socket.id));
socket.on("notification:new", (n) => addNotification(n));
socket.on("notification:read", ({ id }) => markRead(id));
socket.on("notification:read-all", () => clearAll());
socket.emit("chat:message", { room: "lobby", text: "hi" }, (ack) => {
if (!ack.ok) console.error(ack);
});
</script>
Realtime Notifications
The notification system has a built-in socket channel — see Notifications → Realtime delivery for the full integration walkthrough.
Commands
npm run artisan -- make:socket-controller ChatSocketController --method send