@randajan/koa-io-session 1.0.0 → 2.2.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.
@@ -1,111 +1,116 @@
1
- // src/attachSession.js
1
+ // src/class/SessionBridge.js
2
+ import { EventEmitter as EventEmitter2 } from "events";
2
3
  import { ServerResponse } from "http";
3
- import session from "koa-session";
4
- import { solid as solid2 } from "@randajan/props";
4
+ import { solid as solid2, virtual } from "@randajan/props";
5
5
 
6
6
  // src/tools.js
7
7
  import crypto from "crypto";
8
8
  var generateUid = (len = 16) => crypto.randomBytes(len).toString("base64url").slice(0, len);
9
- var isObject = (value) => !!value && typeof value === "object";
10
-
11
- // src/SessionStore.js
12
- import { solid } from "@randajan/props";
13
- import { EventEmitter } from "events";
14
- var wrapStore = (store) => {
15
- return {
16
- get: store.get.bind(store),
17
- set: store.set.bind(store),
18
- destroy: store.destroy.bind(store),
19
- touch: store.touch.bind(store)
20
- };
21
- };
22
- var formatState = (session2, maxAge, prevTTL, defaultTTL) => {
23
- const ttl = maxAge ?? prevTTL ?? defaultTTL;
24
- const expiresAt = Date.now() + ttl;
25
- return { session: session2, expiresAt, ttl };
9
+ var is = (type, any) => typeof any === type;
10
+ var valid = (type, any, req = false, msg = "argument") => {
11
+ if (any == null) {
12
+ if (!req) {
13
+ return;
14
+ }
15
+ throw new Error(`${msg} require typeof '${type}'`);
16
+ }
17
+ if (is(type, any)) {
18
+ return any;
19
+ }
20
+ throw new Error(`${msg} is not typeof '${type}'`);
26
21
  };
27
- var SessionStore = class extends Map {
28
- constructor(defaultTTL = 864e5, eventEmitterOpt = {}) {
29
- super();
30
- solid(this, "defaultTTL", defaultTTL);
31
- solid(this, "event", new EventEmitter(eventEmitterOpt));
22
+ var validRange = (min, max, any, req = false, msg = "argument") => {
23
+ const num = valid("number", any, req, msg);
24
+ if (num == null) {
25
+ return;
32
26
  }
33
- on(eventName, callback) {
34
- return this.event.on(eventName, callback);
27
+ if (num < min) {
28
+ throw new Error(`${msg} must be greater than ${min}`);
35
29
  }
36
- get(sid) {
37
- const d = super.get(sid);
38
- if (!d) {
39
- return;
40
- }
41
- if (Date.now() < d.expiresAt) {
42
- return d.session;
43
- }
44
- this.delete(sid);
30
+ if (num > max) {
31
+ throw new Error(`${msg} must be less than ${max}`);
45
32
  }
46
- touch(sid, maxAge) {
47
- const { defaultTTL } = this;
48
- const d = super.get(sid);
49
- if (!d) {
50
- return false;
51
- }
52
- super.set(sid, formatState(d.session, maxAge, d.ttl, defaultTTL));
53
- this.event.emit("touch", this, sid);
54
- return true;
33
+ return num;
34
+ };
35
+ var validInterval = (any, req = false, msg = "argument") => {
36
+ return validRange(10, 2147483647, any, req, msg);
37
+ };
38
+ var validObject = (any, req = false, msg = "argument") => {
39
+ const obj = valid("object", any, req, msg);
40
+ if (obj == null) {
41
+ return;
55
42
  }
56
- set(sid, session2, maxAge) {
57
- const { defaultTTL } = this;
58
- const d = super.get(sid);
59
- if (session2 == null) {
60
- return !d || this.destroy(sid);
61
- }
62
- super.set(sid, formatState(session2, maxAge, d?.ttl, defaultTTL));
63
- this.event.emit("set", this, sid, !d);
64
- return true;
43
+ if (!Array.isArray(obj)) {
44
+ return obj;
65
45
  }
66
- delete(sid) {
67
- return this.destroy(sid);
46
+ throw new Error(`${msg} must be object, not array`);
47
+ };
48
+ var validStore = (store) => {
49
+ const missing = [];
50
+ if (!is("function", store?.get)) {
51
+ missing.push("get()");
68
52
  }
69
- destroy(sid) {
70
- if (this.has(sid)) {
71
- super.delete(sid);
72
- this.event.emit("destroy", this, sid);
73
- }
74
- return true;
53
+ if (!is("function", store?.set)) {
54
+ missing.push("set()");
75
55
  }
76
- cleanup() {
77
- const now = Date.now();
78
- let cleared = 0;
79
- for (const [sid, d] of this.entries()) {
80
- if (now < d.expiresAt) {
81
- continue;
82
- }
83
- if (this.destroy(sid)) {
84
- cleared++;
85
- }
86
- }
87
- if (cleared) {
88
- this.event.emit("cleanup", this, cleared);
89
- }
90
- return cleared;
56
+ if (!is("function", store?.destroy)) {
57
+ missing.push("destroy()");
91
58
  }
92
- autoCleanup(interval) {
93
- const { defaultTTL } = this;
94
- if (!interval) {
95
- interval = defaultTTL / 10;
96
- }
97
- const tid = setInterval(() => {
98
- this.cleanup();
99
- }, interval);
100
- return (_) => clearInterval(tid);
59
+ if (!is("function", store?.on)) {
60
+ missing.push("on()");
101
61
  }
62
+ if (missing.length) {
63
+ throw new TypeError(`store is missing required API: ${missing.join(", ")}`);
64
+ }
65
+ return store;
66
+ };
67
+
68
+ // src/httpSession.js
69
+ import session from "koa-session";
70
+
71
+ // src/wrappers.js
72
+ var wrapStore = (store) => {
73
+ return {
74
+ get: store.get.bind(store),
75
+ set: store.set.bind(store),
76
+ destroy: store.destroy.bind(store)
77
+ };
78
+ };
79
+ var wrapExternalKey = (opt, onSet) => {
80
+ const { externalKey: base, key, signed } = opt;
81
+ const get = base?.get ? (ctx) => base.get(ctx) : (ctx) => ctx.cookies.get(key, { signed });
82
+ const setRaw = base?.set ? (ctx, sid) => base.set(ctx, sid) : (ctx, sid) => ctx.cookies.set(key, sid, opt);
83
+ const set = typeof onSet != "function" ? setRaw : (ctx, sid) => {
84
+ setRaw(ctx, sid);
85
+ onSet(ctx, sid);
86
+ };
87
+ return { get, set };
88
+ };
89
+
90
+ // src/httpSession.js
91
+ var createKoaSession = (opt, app, onSet) => {
92
+ const store = wrapStore(opt.store);
93
+ const externalKey = wrapExternalKey(opt, onSet);
94
+ const koaSession = session({ ...opt, store, externalKey }, app);
95
+ return [koaSession, externalKey];
96
+ };
97
+ var createClientCookie = (opt) => {
98
+ const { key, maxAge, signed, path, secure, sameSite, httpOnly } = opt;
99
+ return wrapExternalKey({
100
+ key,
101
+ signed,
102
+ maxAge,
103
+ path: path ?? "/",
104
+ secure,
105
+ sameSite,
106
+ httpOnly: httpOnly ?? true,
107
+ overwrite: true
108
+ });
102
109
  };
103
110
 
104
111
  // src/socketSession.js
105
112
  import { solids } from "@randajan/props";
106
- import { createQueue } from "@randajan/queue";
107
113
  var sidLocks = /* @__PURE__ */ new Map();
108
- var touchQueues = /* @__PURE__ */ new Map();
109
114
  var createSessionCtx = (sessionId, session2, socket) => solids({ session: session2 }, { sessionId, socket });
110
115
  var createSessionHash = (session2) => {
111
116
  try {
@@ -141,155 +146,311 @@ var withLock = async (task, socket, ...args) => {
141
146
  }
142
147
  }
143
148
  };
144
- var getTouchQueue = (sid, store) => {
145
- const existing = touchQueues.get(sid);
146
- if (existing) {
147
- return existing;
148
- }
149
- const queue = createQueue(async (touchMaxAge) => {
150
- try {
151
- await store.touch(sid, touchMaxAge);
152
- } catch {
153
- }
154
- if (!queue.isPending) {
155
- touchQueues.delete(sid);
156
- }
157
- }, { pass: "last", softMs: 1e3, hardMs: 5e3 });
158
- touchQueues.set(sid, queue);
159
- return queue;
160
- };
161
- var scheduleTouch = (sid, store, maxAge) => {
162
- if (typeof store?.touch !== "function") {
163
- return;
149
+ var applyOnMissing = (onMissing) => {
150
+ if (onMissing instanceof Error) {
151
+ throw onMissing;
164
152
  }
165
- const queue = getTouchQueue(sid, store);
166
- queue(maxAge);
167
- };
168
- var clearTouchQueue = (sid) => {
169
- const queue = touchQueues.get(sid);
170
- if (!queue) {
171
- return false;
172
- }
173
- queue.flush();
174
- touchQueues.delete(sid);
175
- return true;
153
+ if (is("function", onMissing)) {
154
+ return onMissing();
155
+ }
156
+ return onMissing;
176
157
  };
177
- var runSessionHandler = async (socket, handler, opt = {}) => {
158
+ var runSessionHandler = async (socket, handler, store, onMissing) => {
178
159
  const sid = socket.sessionId;
179
- const { store, maxAge } = opt;
180
160
  const current = await store.get(sid);
181
- if (!isObject(current)) {
182
- throw new Error("Session not found");
161
+ if (!current) {
162
+ return applyOnMissing(onMissing);
183
163
  }
184
164
  const session2 = current;
185
165
  const sessionCtx = createSessionCtx(sid, session2, socket);
186
166
  const originalHash = createSessionHash(sessionCtx.session);
187
167
  const result = await handler(sessionCtx, socket);
188
168
  if (sessionCtx.session == null) {
189
- clearTouchQueue(sid);
190
169
  await store.destroy(sid);
191
170
  return result;
192
171
  }
193
- if (!isObject(sessionCtx.session)) {
194
- throw new TypeError("sessionCtx.session must be an object or null");
195
- }
172
+ sessionCtx.session = validObject(sessionCtx.session, false, "session");
196
173
  if (isSessionHashChanged(originalHash, sessionCtx.session)) {
197
- clearTouchQueue(sid);
198
- await store.set(sid, sessionCtx.session, maxAge);
199
- } else {
200
- scheduleTouch(sid, store, maxAge);
174
+ await store.set(sid, sessionCtx.session);
201
175
  }
202
176
  return result;
203
177
  };
204
- var applySessionHandler = async (socket, handler, opt = {}) => {
178
+ var applySessionHandler = async (socket, handler, store, onMissing) => {
205
179
  if (typeof handler !== "function") {
206
180
  throw new TypeError("socket.withSession(handler) requires a function");
207
181
  }
208
182
  if (!socket.sessionId) {
209
- throw new Error("Missing session id");
183
+ return applyOnMissing(onMissing);
210
184
  }
211
- return withLock(runSessionHandler, socket, handler, opt);
185
+ return withLock(runSessionHandler, socket, handler, store, onMissing);
212
186
  };
213
187
 
214
- // src/attachSession.js
215
- var validateStore = (store) => {
216
- const missing = [];
217
- if (typeof store?.get !== "function") {
218
- missing.push("get()");
188
+ // src/class/SessionStore.js
189
+ import { solid } from "@randajan/props";
190
+ import { EventEmitter } from "events";
191
+
192
+ // src/const.js
193
+ var ms = {
194
+ s: (v = 1) => v * 1e3,
195
+ m: (v = 1) => ms.s(v * 60),
196
+ h: (v = 1) => ms.m(v * 60),
197
+ d: (v = 1) => ms.h(v * 24),
198
+ w: (v = 1) => ms.d(v * 7),
199
+ M: (v = 1) => ms.d(v * 30),
200
+ y: (v = 1) => ms.d(v * 365)
201
+ };
202
+ var _customOptKeys = /* @__PURE__ */ new Set([
203
+ "store",
204
+ "autoCleanup",
205
+ "autoCleanupMs",
206
+ "clientKey",
207
+ "clientMaxAge",
208
+ "clientAlwaysRoll"
209
+ ]);
210
+
211
+ // src/class/SessionStore.js
212
+ var formatState = (session2, maxAge, prevTTL, maxAgeDefault) => {
213
+ const ttl = maxAge ?? prevTTL ?? maxAgeDefault;
214
+ const expiresAt = Date.now() + ttl;
215
+ return { session: session2, expiresAt, ttl };
216
+ };
217
+ var SessionStore = class extends Map {
218
+ constructor(opt = {}) {
219
+ super();
220
+ const maxAge = validRange(ms.s(), ms.y(), opt.maxAge, false, "maxAge") ?? ms.M();
221
+ const autoCleanup = valid("boolean", opt.autoCleanup, false, "autoCleanup") ?? true;
222
+ const autoCleanupMs = validInterval(opt.autoCleanupMs, false, "autoCleanupMs") ?? Math.max(ms.s(), Math.min(ms.h(), maxAge / 10));
223
+ solid(this, "maxAge", maxAge);
224
+ solid(this, "event", new EventEmitter());
225
+ if (!autoCleanup) {
226
+ return;
227
+ }
228
+ setInterval((_) => this.cleanup(), autoCleanupMs);
219
229
  }
220
- if (typeof store?.set !== "function") {
221
- missing.push("set()");
230
+ on(eventName, callback) {
231
+ return this.event.on(eventName, callback);
222
232
  }
223
- if (typeof store?.destroy !== "function") {
224
- missing.push("destroy()");
233
+ get(sid) {
234
+ const d = super.get(sid);
235
+ if (!d) {
236
+ return;
237
+ }
238
+ if (Date.now() < d.expiresAt) {
239
+ return d.session;
240
+ }
241
+ this.delete(sid);
225
242
  }
226
- if (typeof store?.touch !== "function") {
227
- missing.push("touch()");
243
+ set(sid, session2, maxAge) {
244
+ const d = super.get(sid);
245
+ if (session2 == null) {
246
+ return !d || this.destroy(sid);
247
+ }
248
+ super.set(sid, formatState(session2, maxAge, d?.ttl, this.maxAge));
249
+ this.event.emit("set", this, sid, !d);
250
+ return true;
228
251
  }
229
- if (typeof store?.on !== "function") {
230
- missing.push("on()");
252
+ delete(sid) {
253
+ return this.destroy(sid);
231
254
  }
232
- if (missing.length) {
233
- throw new TypeError(`attachSession options.store is missing required API: ${missing.join(", ")}`);
255
+ destroy(sid) {
256
+ if (this.has(sid)) {
257
+ super.delete(sid);
258
+ this.event.emit("destroy", this, sid);
259
+ }
260
+ return true;
261
+ }
262
+ cleanup() {
263
+ const now = Date.now();
264
+ let cleared = 0;
265
+ for (const [sid, d] of this.entries()) {
266
+ if (now < d.expiresAt) {
267
+ continue;
268
+ }
269
+ if (this.destroy(sid)) {
270
+ cleared++;
271
+ }
272
+ }
273
+ if (cleared) {
274
+ this.event.emit("cleanup", this, cleared);
275
+ }
276
+ return cleared;
234
277
  }
235
278
  };
236
- var attachSession = (app, io, opt = {}) => {
237
- opt.signed = "signed" in opt ? !!opt.signed : true;
238
- if (!app.keys) {
239
- app.keys = Array(6).fill().map(() => generateUid(12));
240
- }
241
- if (!opt.key) {
242
- opt.key = generateUid(12);
243
- }
244
- if (!opt.maxAge) {
245
- opt.maxAge = 864e5;
246
- }
247
- const { key, signed, externalKey } = opt;
248
- const store = opt.store || new SessionStore(opt.maxAge);
249
- validateStore(store);
250
- opt.store = wrapStore(store);
251
- const getSID = externalKey ? (ctx) => externalKey.get(ctx) : (ctx) => ctx.cookies.get(key, { signed });
252
- const koaSession = session(opt, app);
253
- app.use(koaSession);
254
- app.use(async (ctx, next) => {
255
- solid2(ctx, "sessionId", getSID(ctx));
256
- await next();
257
- });
258
- io.use(async (socket, next) => {
259
- const req = socket.request;
260
- const res = req.res ?? socket.response ?? new ServerResponse(req);
261
- const ctx = app.createContext(req, res);
262
- await koaSession(ctx, async () => {
279
+
280
+ // src/formatOptions.js
281
+ var pickKoaOpt = (rawOpt) => {
282
+ const koaOpt = {};
283
+ for (const key in rawOpt) {
284
+ if (_customOptKeys.has(key)) {
285
+ continue;
286
+ }
287
+ koaOpt[key] = rawOpt[key];
288
+ }
289
+ koaOpt.key = valid("string", koaOpt.key, false, "key") ?? generateUid(12);
290
+ koaOpt.maxAge = validRange(ms.s(), ms.y(), koaOpt.maxAge, false, "maxAge") ?? ms.M();
291
+ koaOpt.signed = valid("boolean", koaOpt.signed, false, "signed") ?? true;
292
+ koaOpt.store = validStore(rawOpt.store || new SessionStore(rawOpt));
293
+ return koaOpt;
294
+ };
295
+ var formatOptions = (opt = {}) => {
296
+ opt = validObject(opt, true, "options");
297
+ const koaOpt = pickKoaOpt(opt);
298
+ const clientKey = valid("string", opt.clientKey) ?? `${koaOpt.key}.cid`;
299
+ const clientMaxAge = validInterval(opt.clientMaxAge, false, "clientMaxAge") ?? ms.y();
300
+ const clientAlwaysRoll = valid("boolean", opt.clientAlwaysRoll, false, "clientAlwaysRoll") ?? true;
301
+ const clientOpt = { ...koaOpt, key: clientKey, maxAge: clientMaxAge };
302
+ return {
303
+ koaOpt,
304
+ clientOpt,
305
+ clientAlwaysRoll
306
+ };
307
+ };
308
+
309
+ // src/class/Bridge.js
310
+ import { solids as solids2 } from "@randajan/props";
311
+ var Bridge = class {
312
+ constructor(opt = {}) {
313
+ const { onSet, onDelete } = opt;
314
+ solids2(this, {
315
+ onSet,
316
+ onDelete,
317
+ s2c: /* @__PURE__ */ new Map(),
318
+ c2s: /* @__PURE__ */ new Map()
263
319
  });
264
- solid2(socket, "sessionId", getSID(ctx));
265
- solid2(socket, "withSession", async (handler) => {
266
- return applySessionHandler(socket, handler, opt);
267
- }, false);
268
- await next();
269
- });
270
- io.on("connection", (socket) => {
271
- const sid = socket.sessionId;
272
- if (sid) {
273
- socket.join(`sessionId:${sid}`);
320
+ }
321
+ set(cid, sid) {
322
+ if (!cid || !sid) {
323
+ return false;
274
324
  }
275
- });
276
- store.on("destroy", (_store, sid) => {
325
+ const byCid = this.deleteByCid(cid, sid);
326
+ const bySid = this.deleteBySid(sid, cid);
327
+ if (!byCid && !bySid) {
328
+ return false;
329
+ }
330
+ this.c2s.set(cid, sid);
331
+ this.s2c.set(sid, cid);
332
+ this.onSet({ clientId: cid, sessionId: sid });
333
+ return true;
334
+ }
335
+ getByCid(cid) {
336
+ return this.c2s.get(cid);
337
+ }
338
+ getBySid(sid) {
339
+ return this.s2c.get(sid);
340
+ }
341
+ deleteBySid(sid, skipIf) {
342
+ const cid = this.getBySid(sid);
343
+ if (!cid) {
344
+ return true;
345
+ }
346
+ if (skipIf && cid == skipIf) {
347
+ return false;
348
+ }
349
+ this.s2c.delete(sid);
350
+ this.c2s.delete(cid);
351
+ this.onDelete({ clientId: cid, sessionId: sid });
352
+ return true;
353
+ }
354
+ deleteByCid(cid, skipIf) {
355
+ const sid = this.getByCid(cid);
277
356
  if (!sid) {
278
- return;
357
+ return true;
279
358
  }
280
- clearTouchQueue(sid);
281
- const room = io.in(`sessionId:${sid}`);
282
- room.emit("session:destroy");
283
- setTimeout((_) => room.disconnectSockets(true), 200);
284
- });
285
- return store;
359
+ if (skipIf && sid == skipIf) {
360
+ return false;
361
+ }
362
+ this.c2s.delete(cid);
363
+ this.s2c.delete(sid);
364
+ this.onDelete({ clientId: cid, sessionId: sid });
365
+ return true;
366
+ }
367
+ };
368
+
369
+ // src/class/SessionBridge.js
370
+ var SessionBridge = class extends EventEmitter2 {
371
+ constructor(app, io, opt = {}) {
372
+ super();
373
+ if (!app.keys) {
374
+ app.keys = Array(6).fill().map(() => generateUid(12));
375
+ }
376
+ const o = formatOptions(opt);
377
+ const { store } = o.koaOpt;
378
+ const brg = new Bridge({
379
+ onSet: (pair) => this.emit("sessionStart", pair),
380
+ onDelete: (pair) => this.emit("sessionEnd", pair)
381
+ });
382
+ const cc = createClientCookie(o.clientOpt);
383
+ const [koaSession, sc] = createKoaSession(o.koaOpt, app, (ctx, sid) => {
384
+ const cid = cc.get(ctx);
385
+ brg.set(cid, sid);
386
+ });
387
+ const regenerateSid = async (ctx, cid, reqSid) => {
388
+ if (cid == null || reqSid == null) {
389
+ return;
390
+ }
391
+ const brgSid = brg.getByCid(cid);
392
+ if (brgSid == reqSid) {
393
+ return;
394
+ } else if (brgSid) {
395
+ sc.set(ctx, brgSid);
396
+ return;
397
+ }
398
+ if (brg.getBySid(reqSid)) {
399
+ return;
400
+ }
401
+ if (!await store.get(reqSid)) {
402
+ return;
403
+ }
404
+ brg.set(cid, reqSid);
405
+ };
406
+ app.use(koaSession);
407
+ app.use(async (ctx, next) => {
408
+ let cid = cc.get(ctx);
409
+ const sid = sc.get(ctx);
410
+ if (!cid) {
411
+ cc.set(ctx, cid = generateUid(24));
412
+ } else if (o.clientAlwaysRoll) {
413
+ cc.set(ctx, cid);
414
+ }
415
+ await regenerateSid(ctx, cid, sid);
416
+ solid2(ctx, "clientId", cid);
417
+ virtual(ctx, "sessionId", (_) => brg.getByCid(cid));
418
+ await next();
419
+ });
420
+ io.use(async (socket, next) => {
421
+ const req = socket.request;
422
+ const res = req.res ?? socket.response ?? new ServerResponse(req);
423
+ const ctx = app.createContext(req, res);
424
+ await koaSession(ctx, async () => {
425
+ });
426
+ const cid = cc.get(ctx);
427
+ const sid = sc.get(ctx);
428
+ await regenerateSid(ctx, cid, sid);
429
+ solid2(socket, "clientId", cid);
430
+ virtual(socket, "sessionId", (_) => brg.getByCid(cid));
431
+ solid2(socket, "withSession", async function(handler, onMissing) {
432
+ const onm = arguments.length > 1 ? onMissing : new Error("Session missing");
433
+ return applySessionHandler(socket, handler, store, onm);
434
+ }, false);
435
+ await next();
436
+ });
437
+ store.on("destroy", (_store, sid) => {
438
+ if (!sid) {
439
+ return;
440
+ }
441
+ brg.deleteBySid(sid);
442
+ });
443
+ solid2(this, "store", store);
444
+ }
286
445
  };
287
446
 
288
447
  // src/index.js
289
- var index_default = attachSession;
448
+ var bridgeSession = (app, io, opt = {}) => new SessionBridge(app, io, opt);
449
+ var index_default = bridgeSession;
290
450
  export {
451
+ SessionBridge,
291
452
  SessionStore,
292
- attachSession,
453
+ bridgeSession,
293
454
  index_default as default,
294
455
  generateUid
295
456
  };