@randajan/koa-io-session
v3.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 socket.io with one shared session flow.
Why
This library keeps HTTP and WebSocket session work synchronized while preserving native koa-session behavior.
You get:
- standard
ctx.sessionin HTTP ctx.clientIdandsocket.clientIdctx.sessionIdandsocket.sessionIdresolved through bridge mappingsocket.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, io, {
key: "app.sid",
signed: true,
maxAge: 1000 * 60 * 60 * 24,
sameSite: "lax",
httpOnly: true,
secure: false
});
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);API
bridgeSession(app, io, opt)
Creates and returns SessionBridge.
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.clientId,socket.sessionId,socket.withSession(handler, onMissing?)
Methods:
getSessionId(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
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:
store(backend store implementation)maxAge(session TTL used by StoreGateway and koa cookie)autoCleanup(defaultfalse)autoCleanupMs(used only whenautoCleanup === true)clientKey(default${key}.cid)clientMaxAge(default1 year)clientAlwaysRoll(defaulttrue)
Default behavior:
key: random generated when missingsigned:truestore:new LiveStore()app.keys: auto-generated if missing (recommended to set manually in production)autoCleanupMs: when omitted andautoCleanupis enabled, interval is computed asmaxAge / 4, clamped to<1 minute, 1 day>
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, io, {
store: new LiveStore()
});FileStore (persistent, @randajan/file-db)
import { FileStore } from "@randajan/koa-io-session/fdb";
const bridge = bridgeSession(app, io, {
store: new FileStore({ fileName: "sessions" })
});Behavior and Limitations
- Session creation is HTTP-first.
- WebSocket path does not create missing sessions by itself.
- 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.
Exports
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
