Netpress Laravel-inspired backend framework for Node.js
Frameworkv0.5.3 Starterv0.2.2 Docsv1.1.0
Overview Installation Architecture CLI
Features

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:

ArgWhat it is
socketThe connected socket.io socket instance
payloadWhatever the client emitted with the event
context{ user, guard, rooms, handshake, headers } — request metadata
ioThe 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.

  1. The client opens the socket with a token (API token, JWT, whatever
  2. The framework's connection middleware extracts the token, asks the
  3. The user is auto-joined to their personal room (users.{id}).
  4. 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:

  1. socket.handshake.auth.token
  2. socket.handshake.query.token
  3. socket.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. SocketException envelopes 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.token end up in
  • Skipping Socket.use("auth:socket"). Adding auth:socket to a
  • Joining users.{otherId} directly. The room guard catches this,
  • Disabling auth.required for a "public" feature. Anyone with a
  • Trusting payload.userId from 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