@randajan/koa-io-session 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,18 +3,24 @@
3
3
  [![NPM](https://img.shields.io/npm/v/@randajan/koa-io-session.svg)](https://www.npmjs.com/package/@randajan/koa-io-session)
4
4
  [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
5
5
 
6
- Bridge between `koa-session` and `socket.io` with one shared session store.
6
+ Bridge between `koa-session` and `socket.io` with one shared session flow.
7
7
 
8
- ## Why this library
8
+ ## Why
9
9
 
10
- It keeps HTTP and WebSocket session handling in sync while staying close to native `koa-session` behavior.
10
+ This library keeps HTTP and WebSocket session work synchronized while preserving native `koa-session` behavior.
11
11
 
12
12
  You get:
13
- - standard `ctx.session` in HTTP handlers
14
- - `ctx.clientId` and `socket.clientId` for early client tracking
15
- - `ctx.sessionId` and `socket.sessionId` resolved by bridge mapping
16
- - `socket.withSession(handler)` for safe session read/write from WS handlers
17
- - bridge-level events `sessionStart` and `sessionEnd`
13
+ - standard `ctx.session` in HTTP
14
+ - `ctx.clientId` and `socket.clientId`
15
+ - `ctx.sessionId` and `socket.sessionId` resolved through bridge mapping
16
+ - `socket.withSession(handler, onMissing?)` helper
17
+ - bridge events: `sessionSet`, `sessionDestroy`, `cleanup`
18
+
19
+ ## Architecture
20
+
21
+ Public API is `SessionBridge`.
22
+
23
+ Internally, bridge uses a private store layer for TTL/event consistency over your backend `store` (`LiveStore` / `FileStore` / custom).
18
24
 
19
25
  ## Install
20
26
 
@@ -22,7 +28,13 @@ You get:
22
28
  npm i @randajan/koa-io-session
23
29
  ```
24
30
 
25
- ## Quick start
31
+ For persistent file store:
32
+
33
+ ```bash
34
+ npm i @randajan/file-db
35
+ ```
36
+
37
+ ## Quick Start
26
38
 
27
39
  ```js
28
40
  import Koa from "koa";
@@ -31,6 +43,10 @@ import { Server } from "socket.io";
31
43
  import bridgeSession from "@randajan/koa-io-session";
32
44
 
33
45
  const app = new Koa();
46
+
47
+ // Keep keys stable in production (important for signed cookies after restart)
48
+ app.keys = ["your-stable-key-1", "your-stable-key-2"];
49
+
34
50
  const http = createServer(app.callback());
35
51
  const io = new Server(http, {
36
52
  cors: { origin: true, credentials: true }
@@ -39,18 +55,22 @@ const io = new Server(http, {
39
55
  const bridge = bridgeSession(app, io, {
40
56
  key: "app.sid",
41
57
  signed: true,
42
- maxAge: 24 * 60 * 60 * 1000,
58
+ maxAge: 1000 * 60 * 60 * 24,
43
59
  sameSite: "lax",
44
60
  httpOnly: true,
45
61
  secure: false
46
62
  });
47
63
 
48
- bridge.on("sessionStart", ({ clientId, sessionId }) => {
49
- console.log("sessionStart", clientId, sessionId);
64
+ bridge.on("sessionSet", ({ clientId, sessionId, isNew, isInit }) => {
65
+ console.log("sessionSet", { clientId, sessionId, isNew, isInit });
66
+ });
67
+
68
+ bridge.on("sessionDestroy", ({ clientId, sessionId }) => {
69
+ console.log("sessionDestroy", { clientId, sessionId });
50
70
  });
51
71
 
52
- bridge.on("sessionEnd", ({ clientId, sessionId }) => {
53
- console.log("sessionEnd", clientId, sessionId);
72
+ bridge.on("cleanup", (cleared) => {
73
+ console.log("cleanup", cleared);
54
74
  });
55
75
 
56
76
  app.use(async (ctx, next) => {
@@ -68,7 +88,6 @@ app.use(async (ctx, next) => {
68
88
 
69
89
  ctx.body = {
70
90
  ok: true,
71
- from: "http",
72
91
  clientId: ctx.clientId,
73
92
  sessionId: ctx.sessionId,
74
93
  session: ctx.session
@@ -77,22 +96,13 @@ app.use(async (ctx, next) => {
77
96
 
78
97
  io.on("connection", (socket) => {
79
98
  socket.on("session:get", async (ack) => {
80
- try {
81
- const payload = await socket.withSession(async (sessionCtx) => {
82
- return {
83
- ok: true,
84
- from: "ws:get",
85
- clientId: socket.clientId,
86
- sessionId: sessionCtx.sessionId,
87
- session: sessionCtx.session
88
- };
89
- });
90
- if (typeof ack === "function") { ack(payload); }
91
- } catch (error) {
92
- if (typeof ack === "function") {
93
- ack({ ok: false, error: error.message });
94
- }
95
- }
99
+ const payload = await socket.withSession((sessionCtx) => ({
100
+ ok: true,
101
+ sessionId: sessionCtx.sessionId,
102
+ session: sessionCtx.session
103
+ }), { ok: false, error: "missing-session" });
104
+
105
+ if (typeof ack === "function") { ack(payload); }
96
106
  });
97
107
  });
98
108
 
@@ -105,119 +115,175 @@ http.listen(3000);
105
115
 
106
116
  Creates and returns `SessionBridge`.
107
117
 
108
- Default export is `bridgeSession`.
109
-
110
118
  ### `SessionBridge`
111
119
 
112
- `SessionBridge` extends Node.js `EventEmitter`.
113
-
114
- Properties:
115
- - `bridge.store` is the active store instance
120
+ Extends Node.js `EventEmitter`.
116
121
 
117
122
  Events:
118
- - `sessionStart` payload: `{ clientId, sessionId }`
119
- - `sessionEnd` payload: `{ clientId, sessionId }`
123
+ - `sessionSet`: `{ clientId, sessionId, isNew, isInit }`
124
+ - `sessionDestroy`: `{ clientId, sessionId }`
125
+ - `cleanup`: `clearedCount` (number of expired sessions removed)
126
+
127
+ `sessionSet` flags:
128
+ - `isNew`: backend store reported creation of a new persisted session record (`sid` had no previous state)
129
+ - `isInit`: bridge just initialized/attached mapping in current process lifecycle (`clientId <-> sessionId`)
130
+ - typical combinations:
131
+ - `isNew: true`, `isInit: true` -> newly created session was attached now
132
+ - `isNew: false`, `isInit: true` -> existing session was attached now (for example after process restart)
133
+ - `isNew: false`, `isInit: false` -> already attached session was updated
120
134
 
121
135
  Runtime additions:
122
136
  - HTTP context: `ctx.clientId`, `ctx.sessionId`
123
- - Socket: `socket.clientId`, `socket.sessionId`, `socket.withSession(handler)`
124
-
125
- ### `socket.withSession(handler)`
137
+ - socket: `socket.clientId`, `socket.sessionId`, `socket.withSession(handler, onMissing?)`
126
138
 
127
- `handler` receives one object:
139
+ Methods:
140
+ - `getSessionId(clientId): string | undefined`
141
+ - `getClientId(sessionId): string | undefined`
142
+ - `getById(sessionId): Promise<object | undefined>`
143
+ - `getByClientId(clientId): Promise<object | undefined>`
144
+ - `destroyById(sessionId): Promise<boolean>`
145
+ - `destroyByClientId(clientId): Promise<boolean>`
146
+ - `setById(sessionId, session, maxAge?): Promise<boolean>` (cannot create missing session)
147
+ - `setByClientId(clientId, session, maxAge?): Promise<boolean>` (cannot create missing session)
148
+ - `cleanup(): Promise<number>`
149
+ - `startAutoCleanup(interval?): boolean`
150
+ - `stopAutoCleanup(): boolean`
151
+ - `notifyStoreSet(sessionId, isNew?): void`
152
+ - `notifyStoreDestroy(sessionId): void`
153
+ - `notifyStoreCleanup(clearedCount): void`
154
+
155
+ Missing policy:
156
+ - `getBy*` on missing mapping: returns `undefined`
157
+ - `destroyBy*` on missing mapping: returns `false`
158
+ - `setBy*` on missing mapping: throws `Error` (creating via this path is prohibited)
159
+
160
+ ### `socket.withSession(handler, onMissing?)`
161
+
162
+ `handler` receives:
128
163
  - `sessionCtx.sessionId`
129
164
  - `sessionCtx.session`
130
165
  - `sessionCtx.socket`
131
166
 
132
167
  Rules:
133
- - throws if `socket.sessionId` is missing
134
- - throws `"Session not found"` if store does not have the session
168
+ - default `onMissing` is error (`Session is missing for this socket`)
135
169
  - if `sessionCtx.session = null`, session is destroyed
136
170
  - if session changed, store `set` is called
137
- - calls for same `sessionId` are serialized
171
+ - same-session calls are serialized by `sessionId`
172
+
173
+ `onMissing` behavior:
174
+ - `Error` -> throw
175
+ - `function` -> call and return its value
176
+ - any other value -> return as fallback
138
177
 
139
178
  ## Options
140
179
 
141
- `opt` is mostly forwarded to `koa-session`, except internal bridge keys:
180
+ `opt` is mostly forwarded to `koa-session`, with bridge-specific keys:
142
181
 
143
- - `store`
144
- - `autoCleanup`
145
- - `autoCleanupMs`
146
- - `clientKey`
147
- - `clientMaxAge`
148
- - `clientAlwaysRoll`
182
+ - `store` (backend store implementation)
183
+ - `maxAge` (session TTL used by StoreGateway and koa cookie)
184
+ - `autoCleanup` (default `false`)
185
+ - `autoCleanupMs` (used only when `autoCleanup === true`)
186
+ - `clientKey` (default `${key}.cid`)
187
+ - `clientMaxAge` (default `1 year`)
188
+ - `clientAlwaysRoll` (default `true`)
149
189
 
150
- Defaults:
151
- - `key`: random `generateUid(12)`
152
- - `maxAge`: `30 days`
190
+ Default behavior:
191
+ - `key`: random generated when missing
153
192
  - `signed`: `true`
154
- - `store`: `new SessionStore(opt)`
155
- - `clientKey`: `${key}.cid`
156
- - `clientMaxAge`: `1 year`
157
- - `clientAlwaysRoll`: `true`
158
- - `app.keys`: auto-generated if missing
193
+ - `store`: `new LiveStore()`
194
+ - `app.keys`: auto-generated if missing (recommended to set manually in production)
195
+ - `autoCleanupMs`: when omitted and `autoCleanup` is enabled, interval is computed as `maxAge / 4`, clamped to `<1 minute, 1 day>`
159
196
 
160
- Notes:
161
- - set stable `app.keys` in production
162
- - keep cookie settings consistent with your deployment (`sameSite`, `secure`, domain/path)
197
+ ## Store Contract
163
198
 
164
- ## `SessionStore`
199
+ Backend `store` must implement:
200
+ - `get(sid)` -> returns stored state or `undefined`
201
+ - `set(sid, state)` -> returns boolean (or truthy)
202
+ - `destroy(sid)` -> returns boolean
165
203
 
166
- Default in-memory store with TTL.
204
+ Optional:
205
+ - `list()` -> required for cleanup features
206
+ - `optimize(clearedCount)` -> called after cleanup if present
167
207
 
168
- Constructor:
169
- - `new SessionStore({ maxAge, autoCleanup, autoCleanupMs })`
208
+ Stored state format expected by gateway:
209
+ - `{ session, expiresAt, ttl }` where `session` is JSON string (serialized session object)
170
210
 
171
- Methods:
172
- - `get(sid)`
173
- - `set(sid, session, maxAge?)`
174
- - `destroy(sid)` (also used by `delete`)
175
- - `cleanup()`
176
- - `on(eventName, callback)`
211
+ Both sync and async store methods are supported.
212
+
213
+ ## Consistency Rule (Important)
214
+
215
+ After bridge initialization, direct mutation of `opt.store` is unsupported by default.
177
216
 
178
- Events emitted by store:
179
- - `set`
180
- - `destroy`
181
- - `cleanup`
217
+ Why:
218
+ - it bypasses gateway/bridge consistency flow
219
+ - it can break `clientId <-> sessionId` synchronization
220
+ - it can cause missing or misleading bridge events
182
221
 
183
- ## Custom store contract
222
+ Use `SessionBridge` methods (`setBy*`, `destroyBy*`, `cleanup`) for controlled mutations.
184
223
 
185
- Custom store is valid if it implements:
186
- - `get(sid)`
187
- - `set(sid, session, maxAge?)`
188
- - `destroy(sid)`
189
- - `on(eventName, callback)`
224
+ Advanced bypass (you take full responsibility):
225
+ - if you intentionally mutate backend store directly, call matching notify method right after each mutation:
226
+ - `notifyStoreSet(sessionId, isNew?)`
227
+ - `notifyStoreDestroy(sessionId)`
228
+ - `notifyStoreCleanup(clearedCount)`
190
229
 
191
- Sync and async implementations are both supported.
230
+ ## Built-in Stores
192
231
 
193
- Important integration rule:
194
- - your store must emit `destroy` whenever a session is removed, otherwise bridge mapping can get stale
232
+ ### `LiveStore`
195
233
 
196
- ## Behavior and limitations
234
+ In-memory backend store.
235
+
236
+ ```js
237
+ import bridgeSession, { LiveStore } from "@randajan/koa-io-session";
238
+
239
+ const bridge = bridgeSession(app, io, {
240
+ store: new LiveStore()
241
+ });
242
+ ```
243
+
244
+ ### `FileStore` (persistent, `@randajan/file-db`)
245
+
246
+ ```js
247
+ import { FileStore } from "@randajan/koa-io-session/fdb";
248
+
249
+ const bridge = bridgeSession(app, io, {
250
+ store: new FileStore({ fileName: "sessions" })
251
+ });
252
+ ```
253
+
254
+ ## Behavior and Limitations
197
255
 
198
256
  1. Session creation is HTTP-first.
199
- - WebSocket handler does not create missing sessions.
257
+ - WebSocket path does not create missing sessions by itself.
200
258
 
201
- 2. Bridge mapping is in-memory.
202
- - After process restart, mapping is rebuilt from incoming cookies plus store state.
259
+ 2. Mapping (`clientId <-> sessionId`) is in-memory.
260
+ - After process restart, mapping is rebuilt from incoming cookies and existing store state.
203
261
 
204
- 3. Signed cookies depend on stable keys.
205
- - If `app.keys` change, previously signed cookies become invalid.
262
+ 3. Signed cookies depend on stable `app.keys`.
263
+ - Changing keys invalidates previous signed cookies.
206
264
 
207
- 4. Change detection in WS uses `JSON.stringify`.
208
- - Non-serializable or cyclic data are not recommended in session payloads.
265
+ 4. WS change detection uses `JSON.stringify`.
266
+ - Non-serializable/cyclic payloads are not recommended in session data.
209
267
 
210
268
  ## Exports
211
269
 
270
+ Main entry:
271
+
212
272
  ```js
213
273
  import bridgeSession, {
214
274
  bridgeSession,
215
275
  SessionBridge,
216
- SessionStore,
276
+ LiveStore,
217
277
  generateUid
218
278
  } from "@randajan/koa-io-session";
219
279
  ```
220
280
 
281
+ Persistent file store entry:
282
+
283
+ ```js
284
+ import { FileStore } from "@randajan/koa-io-session/fdb";
285
+ ```
286
+
221
287
  ## License
222
288
 
223
289
  MIT (c) [randajan](https://github.com/randajan)