@randajan/koa-io-session
v4.0.0
Published
Simple bridge between koa-session and socket.io. Shares a unified session across HTTP and WebSocket using a common session store.
Maintainers
Readme
@randajan/koa-io-session
Bridge between koa-session and selected socket.io servers or namespaces with one shared session flow.
Version 4.0.0
Version 4.0.0 changes the initialization API.
SessionBridge is now created for the Koa application first, and Socket.IO servers or namespaces are attached explicitly with bridge.attach(io).
This is a breaking change from the old bridgeSession(app, io, opt) API.
Why:
- Koa middleware is application-wide.
- Socket.IO middleware is server/namespace-specific.
- Explicit
attach()makes it clear which namespaces receive session support. - Multiple Socket.IO servers or namespaces can share the same bridge.
Why
This library keeps HTTP and selected WebSocket session work synchronized while preserving native koa-session behavior.
You get:
- standard
ctx.sessionin HTTP ctx.clientIdandsocket.clientIdctx.sessionIdandsocket.sessionIdresolved through bridge mappingsocket.ctx(Koa context created from socket handshake request with an internal response object)socket.withSession(handler, onMissing?)helper- bridge events:
sessionSet,sessionDestroy,cleanup
Architecture
Public API is SessionBridge.
Internally, bridge uses a private store layer for TTL/event consistency over your backend store (LiveStore / FileStore / custom).
Install
npm i @randajan/koa-io-sessionFor persistent file store:
npm i @randajan/file-dbQuick Start
import Koa from "koa";
import { createServer } from "http";
import { Server } from "socket.io";
import bridgeSession from "@randajan/koa-io-session";
const app = new Koa();
// Keep keys stable in production (important for signed cookies after restart)
app.keys = ["your-stable-key-1", "your-stable-key-2"];
const http = createServer(app.callback());
const io = new Server(http, {
cors: { origin: true, credentials: true }
});
const bridge = bridgeSession(app, {
key: "app.sid",
signed: true,
maxAge: 1000 * 60 * 60 * 24,
sameSite: "lax",
httpOnly: true,
secure: false
});
bridge.attach(io);
bridge.on("sessionSet", ({ clientId, sessionId, isNew, isInit }) => {
console.log("sessionSet", { clientId, sessionId, isNew, isInit });
});
bridge.on("sessionDestroy", ({ clientId, sessionId }) => {
console.log("sessionDestroy", { clientId, sessionId });
});
bridge.on("cleanup", (cleared) => {
console.log("cleanup", cleared);
});
app.use(async (ctx, next) => {
if (ctx.path !== "/api/session") { return next(); }
if (ctx.query.reset === "1") {
ctx.session = null;
ctx.body = { ok: true, from: "http:reset" };
return;
}
if (!ctx.session.createdAt) { ctx.session.createdAt = Date.now(); }
if (!Number.isFinite(ctx.session.httpCount)) { ctx.session.httpCount = 0; }
ctx.session.httpCount += 1;
ctx.body = {
ok: true,
clientId: ctx.clientId,
sessionId: ctx.sessionId,
session: ctx.session
};
});
io.on("connection", (socket) => {
socket.on("session:get", async (ack) => {
const payload = await socket.withSession((sessionCtx) => ({
ok: true,
sessionId: sessionCtx.sessionId,
session: sessionCtx.session
}), { ok: false, error: "missing-session" });
if (typeof ack === "function") { ack(payload); }
});
});
http.listen(3000);Attaching Namespaces
Attach only the namespaces that should receive session support:
const bridge = bridgeSession(app, {
key: "app.sid",
signed: true
});
const publicIo = io.of("/public");
const adminIo = io.of("/admin");
const rawIo = io.of("/raw");
bridge.attach(publicIo);
bridge.attach(adminIo);
publicIo.on("connection", (socket) => {
console.log(socket.clientId, socket.sessionId);
});
adminIo.on("connection", (socket) => {
socket.withSession(async (sessionCtx) => {
return sessionCtx.session;
});
});
rawIo.on("connection", (socket) => {
// No session bridge is attached here.
});API
bridgeSession(app, opt)
Creates and returns SessionBridge.
It installs the Koa/HTTP side of the bridge on app.
It does not attach Socket.IO middleware by itself. Use bridge.attach(io) for each Socket.IO server or namespace that should use sessions.
SessionBridge
Extends Node.js EventEmitter.
Events:
sessionSet:{ clientId, sessionId, isNew, isInit }sessionDestroy:{ clientId, sessionId }cleanup:clearedCount(number of expired sessions removed)
sessionSet flags:
isNew: backend store reported creation of a new persisted session record (sidhad no previous state)isInit: bridge just initialized/attached mapping in current process lifecycle (clientId <-> sessionId)- typical combinations:
isNew: true,isInit: true-> newly created session was attached nowisNew: false,isInit: true-> existing session was attached now (for example after process restart)isNew: false,isInit: false-> already attached session was updated
Runtime additions:
- HTTP context:
ctx.clientId,ctx.sessionId - socket:
socket.ctx,socket.clientId,socket.sessionId,socket.withSession(handler, onMissing?) socket.ctxis created during socket initialization from the handshake request and an internal response object
Methods:
attach(io): thisgetSessionId(clientId): string | undefinedgetClientId(sessionId): string | undefinedgetById(sessionId): Promise<object | undefined>getByClientId(clientId): Promise<object | undefined>destroyById(sessionId): Promise<boolean>destroyByClientId(clientId): Promise<boolean>setById(sessionId, session, maxAge?): Promise<boolean>(cannot create missing session)setByClientId(clientId, session, maxAge?): Promise<boolean>(cannot create missing session)cleanup(): Promise<number>startAutoCleanup(interval?): booleanstopAutoCleanup(): booleannotifyStoreSet(sessionId, isNew?): voidnotifyStoreDestroy(sessionId): voidnotifyStoreCleanup(clearedCount): void
bridge.attach(io)
Attaches the Socket.IO side of the bridge to a Server or Namespace.
const bridge = bridgeSession(app, opt);
bridge.attach(io);
bridge.attach(io.of("/admin"));
bridge.attach(io.of("/app"));Only attached Socket.IO targets receive:
socket.ctxsocket.clientIdsocket.sessionIdsocket.withSession(handler, onMissing?)
Calling attach() twice with the same target throws an error.
Namespaces that are not attached are left untouched.
Missing policy:
getBy*on missing mapping: returnsundefineddestroyBy*on missing mapping: returnsfalsesetBy*on missing mapping: throwsError(creating via this path is prohibited)
socket.withSession(handler, onMissing?)
handler receives:
sessionCtx.sessionIdsessionCtx.sessionsessionCtx.socket
Rules:
- default
onMissingis error (Session is missing for this socket) - if
sessionCtx.session = null, session is destroyed - if session changed, store
setis called - same-session calls are serialized by
sessionId
onMissing behavior:
Error-> throwfunction-> call and return its value- any other value -> return as fallback
Options
opt is mostly forwarded to koa-session, with bridge-specific keys:
appKeys(optional array used to initializeapp.keys)allowRndAppKeys(defaultfalse; suppress runtime warning when keys are generated)store(backend store implementation)maxAge(session TTL used by StoreGateway and koa cookie)autoCleanup(defaultfalse)autoCleanupMs(used only whenautoCleanup === true)clientKey(default"cid")clientMaxAge(default1 year)clientAlwaysRoll(defaulttrue)
Default behavior:
key:"sid"when missingsigned:truestore:new LiveStore()app.keys: if missing, bridge generates 2 runtime keys (length 32) and logs warningautoCleanupMs: when omitted andautoCleanupis enabled, interval is computed asmaxAge / 4, clamped to<1 minute, 1 day>
app.keys resolution:
- if
app.keysalready exists andappKeysis not provided: existingapp.keysis used - if
app.keysalready exists andappKeysis provided: throws error - if
app.keysis missing andappKeysis provided:app.keys = appKeys - if both are missing: bridge generates runtime keys; warning is shown unless
allowRndAppKeys === true
Store Contract
Backend store must implement:
get(sid)-> returns stored state orundefinedset(sid, state)-> returns boolean (or truthy)destroy(sid)-> returns boolean
Optional:
list()-> required for cleanup featuresoptimize(clearedCount)-> called after cleanup if present
Stored state format expected by gateway:
{ session, expiresAt, ttl }wheresessionis JSON string (serialized session object)
Both sync and async store methods are supported.
Consistency Rule (Important)
After bridge initialization, direct mutation of opt.store is unsupported by default.
Why:
- it bypasses gateway/bridge consistency flow
- it can break
clientId <-> sessionIdsynchronization - it can cause missing or misleading bridge events
Use SessionBridge methods (setBy*, destroyBy*, cleanup) for controlled mutations.
Advanced bypass (you take full responsibility):
- if you intentionally mutate backend store directly, call matching notify method right after each mutation:
notifyStoreSet(sessionId, isNew?)notifyStoreDestroy(sessionId)notifyStoreCleanup(clearedCount)
Built-in Stores
LiveStore
In-memory backend store.
import bridgeSession, { LiveStore } from "@randajan/koa-io-session";
const bridge = bridgeSession(app, {
store: new LiveStore()
});
bridge.attach(io);FileStore (persistent, @randajan/file-db)
import { FileStore } from "@randajan/koa-io-session/fdb";
const bridge = bridgeSession(app, {
store: new FileStore({ fileName: "sessions" })
});
bridge.attach(io);Behavior and Limitations
- Session creation is HTTP-first.
- WebSocket path does not create missing sessions by itself.
- Socket.IO support is opt-in per target.
- Call
bridge.attach(io)for each Socket.IO server or namespace that should receive session support. - Namespaces without
attach()do not getsocket.ctx,socket.clientId,socket.sessionId, orsocket.withSession().
- The HTTP side is global for the Koa application.
- Creating a bridge installs Koa middleware on
app. - Socket.IO middleware is installed only by
attach().
- Socket initialization uses an internal response object.
- Signed cookie reads may ask the cookie layer to refresh or clear signatures.
- During Socket.IO initialization, those response headers are not sent to the client.
- Cookie repair is expected to happen on a normal HTTP request.
- Mapping (
clientId <-> sessionId) is in-memory.
- After process restart, mapping is rebuilt from incoming cookies and existing store state.
- Signed cookies depend on stable
app.keys.
- Changing keys invalidates previous signed cookies.
- WS change detection uses
JSON.stringify.
- Non-serializable/cyclic payloads are not recommended in session data.
Migrating From 3.x
Version 3.x:
const bridge = bridgeSession(app, io, opt);Version 4.0.0:
const bridge = bridgeSession(app, opt);
bridge.attach(io);For namespaces:
const bridge = bridgeSession(app, opt);
bridge.attach(io.of("/a"));
bridge.attach(io.of("/admin"));
io.of("/raw"); // no session bridgeExports
Main entry:
import bridgeSession, {
bridgeSession,
SessionBridge,
LiveStore,
generateUid
} from "@randajan/koa-io-session";Persistent file store entry:
import { FileStore } from "@randajan/koa-io-session/fdb";License
MIT (c) randajan
