@randajan/koa-io-session 2.2.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, onMissing?)` for strict or optional WS session handling
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,131 +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, onMissing?)`
137
+ - socket: `socket.clientId`, `socket.sessionId`, `socket.withSession(handler, onMissing?)`
138
+
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)
124
159
 
125
160
  ### `socket.withSession(handler, onMissing?)`
126
161
 
127
- `handler` receives one object:
162
+ `handler` receives:
128
163
  - `sessionCtx.sessionId`
129
164
  - `sessionCtx.session`
130
165
  - `sessionCtx.socket`
131
166
 
132
167
  Rules:
133
- - default behavior (`onMissing` not provided): throws `Error("Session missing")`
134
- - missing session means `socket.sessionId` is missing
135
- - missing session means store does not have session for current sid
168
+ - default `onMissing` is error (`Session is missing for this socket`)
136
169
  - if `sessionCtx.session = null`, session is destroyed
137
170
  - if session changed, store `set` is called
138
- - calls for same `sessionId` are serialized
171
+ - same-session calls are serialized by `sessionId`
139
172
 
140
173
  `onMissing` behavior:
141
- - if `onMissing` is an `Error`, it is thrown
142
- - if `onMissing` is a function, its return value is used
143
- - otherwise, `onMissing` value is returned as-is
144
-
145
- Examples:
146
- - strict (default): `await socket.withSession(handler)`
147
- - silent undefined on missing: `await socket.withSession(handler, undefined)`
148
- - custom fallback value: `await socket.withSession(handler, { ok: false })`
149
- - custom fallback callback: `await socket.withSession(handler, () => ({ ok: false }))`
174
+ - `Error` -> throw
175
+ - `function` -> call and return its value
176
+ - any other value -> return as fallback
150
177
 
151
178
  ## Options
152
179
 
153
- `opt` is mostly forwarded to `koa-session`, except internal bridge keys:
180
+ `opt` is mostly forwarded to `koa-session`, with bridge-specific keys:
154
181
 
155
- - `store`
156
- - `autoCleanup`
157
- - `autoCleanupMs`
158
- - `clientKey`
159
- - `clientMaxAge`
160
- - `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`)
161
189
 
162
- Defaults:
163
- - `key`: random `generateUid(12)`
164
- - `maxAge`: `30 days`
190
+ Default behavior:
191
+ - `key`: random generated when missing
165
192
  - `signed`: `true`
166
- - `store`: `new SessionStore(opt)`
167
- - `clientKey`: `${key}.cid`
168
- - `clientMaxAge`: `1 year`
169
- - `clientAlwaysRoll`: `true`
170
- - `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>`
171
196
 
172
- Notes:
173
- - set stable `app.keys` in production
174
- - keep cookie settings consistent with your deployment (`sameSite`, `secure`, domain/path)
197
+ ## Store Contract
175
198
 
176
- ## `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
177
203
 
178
- Default in-memory store with TTL.
204
+ Optional:
205
+ - `list()` -> required for cleanup features
206
+ - `optimize(clearedCount)` -> called after cleanup if present
179
207
 
180
- Constructor:
181
- - `new SessionStore({ maxAge, autoCleanup, autoCleanupMs })`
208
+ Stored state format expected by gateway:
209
+ - `{ session, expiresAt, ttl }` where `session` is JSON string (serialized session object)
182
210
 
183
- Methods:
184
- - `get(sid)`
185
- - `set(sid, session, maxAge?)`
186
- - `destroy(sid)` (also used by `delete`)
187
- - `cleanup()`
188
- - `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.
189
216
 
190
- Events emitted by store:
191
- - `set`
192
- - `destroy`
193
- - `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
194
221
 
195
- ## Custom store contract
222
+ Use `SessionBridge` methods (`setBy*`, `destroyBy*`, `cleanup`) for controlled mutations.
196
223
 
197
- Custom store is valid if it implements:
198
- - `get(sid)`
199
- - `set(sid, session, maxAge?)`
200
- - `destroy(sid)`
201
- - `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)`
202
229
 
203
- Sync and async implementations are both supported.
230
+ ## Built-in Stores
204
231
 
205
- Important integration rule:
206
- - your store must emit `destroy` whenever a session is removed, otherwise bridge mapping can get stale
232
+ ### `LiveStore`
207
233
 
208
- ## 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
209
255
 
210
256
  1. Session creation is HTTP-first.
211
- - WebSocket handler does not create missing sessions.
257
+ - WebSocket path does not create missing sessions by itself.
212
258
 
213
- 2. Bridge mapping is in-memory.
214
- - 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.
215
261
 
216
- 3. Signed cookies depend on stable keys.
217
- - 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.
218
264
 
219
- 4. Change detection in WS uses `JSON.stringify`.
220
- - 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.
221
267
 
222
268
  ## Exports
223
269
 
270
+ Main entry:
271
+
224
272
  ```js
225
273
  import bridgeSession, {
226
274
  bridgeSession,
227
275
  SessionBridge,
228
- SessionStore,
276
+ LiveStore,
229
277
  generateUid
230
278
  } from "@randajan/koa-io-session";
231
279
  ```
232
280
 
281
+ Persistent file store entry:
282
+
283
+ ```js
284
+ import { FileStore } from "@randajan/koa-io-session/fdb";
285
+ ```
286
+
233
287
  ## License
234
288
 
235
289
  MIT (c) [randajan](https://github.com/randajan)