@yaebal/session 0.0.1
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/LICENSE +21 -0
- package/README.md +13 -0
- package/lib/index.d.ts +30 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +41 -0
- package/lib/index.js.map +1 -0
- package/lib/index.test.d.ts +2 -0
- package/lib/index.test.d.ts.map +1 -0
- package/lib/index.test.js +106 -0
- package/lib/index.test.js.map +1 -0
- package/package.json +48 -0
- package/src/index.test.ts +130 -0
- package/src/index.ts +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 neverlane
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @yaebal/session
|
|
2
|
+
|
|
3
|
+
a pluggable session store. implement this to back sessions with a file, redis, etc.
|
|
4
|
+
|
|
5
|
+
## install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @yaebal/session
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
part of [**yaebal**](https://github.com/neverlane/yaebal) — a type-safe, runtime-agnostic Telegram Bot API framework. MIT.
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Context, Plugin } from "@yaebal/core";
|
|
2
|
+
/** a pluggable session store. implement this to back sessions with a file, redis, etc. */
|
|
3
|
+
export interface StorageAdapter<T> {
|
|
4
|
+
get(key: string): T | undefined | Promise<T | undefined>;
|
|
5
|
+
set(key: string, value: T): unknown | Promise<unknown>;
|
|
6
|
+
delete(key: string): unknown | Promise<unknown>;
|
|
7
|
+
}
|
|
8
|
+
/** defaults to in-memory store. lost on restart — swap for a persistent adapter in production. */
|
|
9
|
+
export declare class MemoryStorage<T> implements StorageAdapter<T> {
|
|
10
|
+
#private;
|
|
11
|
+
get(key: string): T | undefined;
|
|
12
|
+
set(key: string, value: T): void;
|
|
13
|
+
delete(key: string): void;
|
|
14
|
+
}
|
|
15
|
+
export interface SessionOptions<S> {
|
|
16
|
+
/** build a fresh session when none is stored. required so the type is honest. */
|
|
17
|
+
initial: () => S;
|
|
18
|
+
/** where to persist sessions. defaults to in-memory. */
|
|
19
|
+
storage?: StorageAdapter<S>;
|
|
20
|
+
/** session key for an update. defaults to per-chat (`ctx.chat.id`). */
|
|
21
|
+
getKey?: (ctx: Context) => string | undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* session plugin: loads `ctx.session` before handlers run and persists it after.
|
|
25
|
+
* per-chat by default (the grammY convention); override `getKey` for per-user.
|
|
26
|
+
*/
|
|
27
|
+
export declare function session<S>(options: SessionOptions<S>): Plugin<Context, {
|
|
28
|
+
session: S;
|
|
29
|
+
}>;
|
|
30
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAEpD,0FAA0F;AAC1F,MAAM,WAAW,cAAc,CAAC,CAAC;IAChC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;IACzD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACvD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAChD;AAED,kGAAkG;AAClG,qBAAa,aAAa,CAAC,CAAC,CAAE,YAAW,cAAc,CAAC,CAAC,CAAC;;IAGzD,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAI/B,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,IAAI;IAIhC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAGzB;AAED,MAAM,WAAW,cAAc,CAAC,CAAC;IAChC,iFAAiF;IACjF,OAAO,EAAE,MAAM,CAAC,CAAC;IACjB,wDAAwD;IACxD,OAAO,CAAC,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC;IAC5B,uEAAuE;IACvE,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC;CAC9C;AAED;;;GAGG;AACH,wBAAgB,OAAO,CAAC,CAAC,EAAE,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,OAAO,EAAE;IAAE,OAAO,EAAE,CAAC,CAAA;CAAE,CAAC,CA2BtF"}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** defaults to in-memory store. lost on restart — swap for a persistent adapter in production. */
|
|
2
|
+
export class MemoryStorage {
|
|
3
|
+
#map = new Map();
|
|
4
|
+
get(key) {
|
|
5
|
+
return this.#map.get(key);
|
|
6
|
+
}
|
|
7
|
+
set(key, value) {
|
|
8
|
+
this.#map.set(key, value);
|
|
9
|
+
}
|
|
10
|
+
delete(key) {
|
|
11
|
+
this.#map.delete(key);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* session plugin: loads `ctx.session` before handlers run and persists it after.
|
|
16
|
+
* per-chat by default (the grammY convention); override `getKey` for per-user.
|
|
17
|
+
*/
|
|
18
|
+
export function session(options) {
|
|
19
|
+
const { initial } = options;
|
|
20
|
+
const storage = options.storage ?? new MemoryStorage();
|
|
21
|
+
const getKey = options.getKey ?? ((ctx) => ctx.chat?.id?.toString());
|
|
22
|
+
return (composer) => composer
|
|
23
|
+
// post-next save. wraps the derive below; runs after handlers, then persists.
|
|
24
|
+
.use(async (ctx, next) => {
|
|
25
|
+
const key = getKey(ctx);
|
|
26
|
+
await next();
|
|
27
|
+
// no key for this update (e.g. a channel post): the session was a throwaway.
|
|
28
|
+
if (key === undefined)
|
|
29
|
+
return;
|
|
30
|
+
// ponytail: writes back unconditionally. add a dirty-check only if a remote
|
|
31
|
+
// storage adapter makes the extra write measurably expensive.
|
|
32
|
+
await storage.set(key, ctx.session);
|
|
33
|
+
})
|
|
34
|
+
// load (or initialise) the session before handlers run; the field flows typed.
|
|
35
|
+
.derive(async (ctx) => {
|
|
36
|
+
const key = getKey(ctx);
|
|
37
|
+
const value = key === undefined ? initial() : ((await storage.get(key)) ?? initial());
|
|
38
|
+
return { session: value };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,kGAAkG;AAClG,MAAM,OAAO,aAAa;IACzB,IAAI,GAAG,IAAI,GAAG,EAAa,CAAC;IAE5B,GAAG,CAAC,GAAW;QACd,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC3B,CAAC;IAED,GAAG,CAAC,GAAW,EAAE,KAAQ;QACxB,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAC3B,CAAC;IAED,MAAM,CAAC,GAAW;QACjB,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACvB,CAAC;CACD;AAWD;;;GAGG;AACH,MAAM,UAAU,OAAO,CAAI,OAA0B;IACpD,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;IAE5B,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,IAAI,aAAa,EAAK,CAAC;IAC1D,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC,GAAY,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAC;IAE9E,OAAO,CAAC,QAAQ,EAAE,EAAE,CACnB,QAAQ;QACP,8EAA8E;SAC7E,GAAG,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QACxB,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;QACxB,MAAM,IAAI,EAAE,CAAC;QAEb,6EAA6E;QAC7E,IAAI,GAAG,KAAK,SAAS;YAAE,OAAO;QAE9B,4EAA4E;QAC5E,8DAA8D;QAC9D,MAAM,OAAO,CAAC,GAAG,CAAC,GAAG,EAAG,GAAiC,CAAC,OAAO,CAAC,CAAC;IACpE,CAAC,CAAC;QACF,+EAA+E;SAC9E,MAAM,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE;QACrB,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;QACxB,MAAM,KAAK,GAAG,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,IAAI,OAAO,EAAE,CAAC,CAAC;QAEtF,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;IAC3B,CAAC,CAAC,CAAC;AACN,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Composer, Context } from "@yaebal/core";
|
|
4
|
+
import { MemoryStorage, session } from "./index.js";
|
|
5
|
+
const api = {};
|
|
6
|
+
const noop = async () => { };
|
|
7
|
+
// the entry ctx is a bare Context; the session middleware adds `session` itself.
|
|
8
|
+
// mirror Bot.start()'s cast so the runnable type matches the runtime reality.
|
|
9
|
+
const entry = (c) => c.toMiddleware();
|
|
10
|
+
function mkCtx(chatId) {
|
|
11
|
+
return new Context({
|
|
12
|
+
api,
|
|
13
|
+
update: {
|
|
14
|
+
update_id: 1,
|
|
15
|
+
message: {
|
|
16
|
+
message_id: 1,
|
|
17
|
+
date: 0,
|
|
18
|
+
chat: { id: chatId, type: "private" },
|
|
19
|
+
from: { id: chatId, is_bot: false, first_name: "u" },
|
|
20
|
+
text: "hi",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
updateType: "message",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
test("MemoryStorage round-trips values", () => {
|
|
27
|
+
const s = new MemoryStorage();
|
|
28
|
+
s.set("a", 1);
|
|
29
|
+
assert.equal(s.get("a"), 1);
|
|
30
|
+
s.delete("a");
|
|
31
|
+
assert.equal(s.get("a"), undefined);
|
|
32
|
+
});
|
|
33
|
+
test("session persists per chat across updates", async () => {
|
|
34
|
+
const storage = new MemoryStorage();
|
|
35
|
+
const c = new Composer()
|
|
36
|
+
.install(session({ initial: () => ({ count: 0 }), storage }))
|
|
37
|
+
.use((ctx, next) => {
|
|
38
|
+
ctx.session.count++;
|
|
39
|
+
return next();
|
|
40
|
+
});
|
|
41
|
+
const mw = entry(c);
|
|
42
|
+
await mw(mkCtx(42), noop);
|
|
43
|
+
await mw(mkCtx(42), noop);
|
|
44
|
+
await mw(mkCtx(99), noop);
|
|
45
|
+
assert.equal((await storage.get("42"))?.count, 2);
|
|
46
|
+
assert.equal((await storage.get("99"))?.count, 1);
|
|
47
|
+
});
|
|
48
|
+
test("session falls back to initial when nothing stored", async () => {
|
|
49
|
+
const storage = new MemoryStorage();
|
|
50
|
+
let seen = -1;
|
|
51
|
+
const c = new Composer()
|
|
52
|
+
.install(session({ initial: () => ({ count: 7 }), storage }))
|
|
53
|
+
.use((ctx, next) => {
|
|
54
|
+
seen = ctx.session.count;
|
|
55
|
+
return next();
|
|
56
|
+
});
|
|
57
|
+
await entry(c)(mkCtx(1), noop);
|
|
58
|
+
assert.equal(seen, 7);
|
|
59
|
+
});
|
|
60
|
+
test("a custom getKey switches the partition", async () => {
|
|
61
|
+
const storage = new MemoryStorage();
|
|
62
|
+
const c = new Composer()
|
|
63
|
+
.install(session({ initial: () => ({ n: 0 }), storage, getKey: () => "global" }))
|
|
64
|
+
.use((ctx, next) => {
|
|
65
|
+
ctx.session.n++;
|
|
66
|
+
return next();
|
|
67
|
+
});
|
|
68
|
+
const mw = entry(c);
|
|
69
|
+
await mw(mkCtx(1), noop);
|
|
70
|
+
await mw(mkCtx(2), noop);
|
|
71
|
+
assert.equal((await storage.get("global"))?.n, 2);
|
|
72
|
+
});
|
|
73
|
+
test("keyless update gets a working, non-persisted session", async () => {
|
|
74
|
+
const storage = new MemoryStorage();
|
|
75
|
+
let seen = -1;
|
|
76
|
+
const c = new Composer()
|
|
77
|
+
.install(session({ initial: () => ({ count: 3 }), storage }))
|
|
78
|
+
.use((ctx, next) => {
|
|
79
|
+
ctx.session.count++;
|
|
80
|
+
seen = ctx.session.count;
|
|
81
|
+
return next();
|
|
82
|
+
});
|
|
83
|
+
// an update with no chat (e.g. a poll) → no key
|
|
84
|
+
const keyless = new Context({
|
|
85
|
+
api,
|
|
86
|
+
update: { update_id: 1 },
|
|
87
|
+
updateType: "poll",
|
|
88
|
+
});
|
|
89
|
+
await entry(c)(keyless, noop);
|
|
90
|
+
assert.equal(seen, 4); // session was usable
|
|
91
|
+
assert.equal(await storage.get("0"), undefined); // nothing persisted
|
|
92
|
+
});
|
|
93
|
+
test("a throwing handler leaves storage untouched", async () => {
|
|
94
|
+
const storage = new MemoryStorage();
|
|
95
|
+
const c = new Composer()
|
|
96
|
+
.install(session({ initial: () => ({ count: 0 }), storage }))
|
|
97
|
+
.use((ctx) => {
|
|
98
|
+
ctx.session.count++;
|
|
99
|
+
throw new Error("boom");
|
|
100
|
+
});
|
|
101
|
+
await assert.rejects(async () => {
|
|
102
|
+
await entry(c)(mkCtx(5), noop);
|
|
103
|
+
}, /boom/);
|
|
104
|
+
assert.equal(await storage.get("5"), undefined);
|
|
105
|
+
});
|
|
106
|
+
//# sourceMappingURL=index.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.js","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yaebal/session",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "yaebal session plugin — per-chat state with pluggable storage adapters.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.js",
|
|
7
|
+
"types": "./lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"import": "./lib/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"lib",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@yaebal/core": "0.0.1"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "latest"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"telegram",
|
|
29
|
+
"telegram-bot",
|
|
30
|
+
"yaebal",
|
|
31
|
+
"session",
|
|
32
|
+
"storage"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/neverlane/yaebal",
|
|
38
|
+
"directory": "packages/session"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
46
|
+
"test": "node --test lib"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Composer, Context, type Middleware } from "@yaebal/core";
|
|
4
|
+
import { MemoryStorage, session } from "./index.js";
|
|
5
|
+
|
|
6
|
+
const api = {} as never;
|
|
7
|
+
const noop = async () => {};
|
|
8
|
+
|
|
9
|
+
// the entry ctx is a bare Context; the session middleware adds `session` itself.
|
|
10
|
+
// mirror Bot.start()'s cast so the runnable type matches the runtime reality.
|
|
11
|
+
const entry = <C extends Context>(c: Composer<C>) =>
|
|
12
|
+
c.toMiddleware() as unknown as Middleware<Context>;
|
|
13
|
+
|
|
14
|
+
function mkCtx(chatId: number): Context {
|
|
15
|
+
return new Context({
|
|
16
|
+
api,
|
|
17
|
+
update: {
|
|
18
|
+
update_id: 1,
|
|
19
|
+
message: {
|
|
20
|
+
message_id: 1,
|
|
21
|
+
date: 0,
|
|
22
|
+
chat: { id: chatId, type: "private" },
|
|
23
|
+
from: { id: chatId, is_bot: false, first_name: "u" },
|
|
24
|
+
text: "hi",
|
|
25
|
+
},
|
|
26
|
+
} as never,
|
|
27
|
+
updateType: "message",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test("MemoryStorage round-trips values", () => {
|
|
32
|
+
const s = new MemoryStorage<number>();
|
|
33
|
+
|
|
34
|
+
s.set("a", 1);
|
|
35
|
+
assert.equal(s.get("a"), 1);
|
|
36
|
+
|
|
37
|
+
s.delete("a");
|
|
38
|
+
assert.equal(s.get("a"), undefined);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("session persists per chat across updates", async () => {
|
|
42
|
+
const storage = new MemoryStorage<{ count: number }>();
|
|
43
|
+
const c = new Composer<Context>()
|
|
44
|
+
.install(session({ initial: () => ({ count: 0 }), storage }))
|
|
45
|
+
.use((ctx, next) => {
|
|
46
|
+
ctx.session.count++;
|
|
47
|
+
return next();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const mw = entry(c);
|
|
51
|
+
|
|
52
|
+
await mw(mkCtx(42), noop);
|
|
53
|
+
await mw(mkCtx(42), noop);
|
|
54
|
+
await mw(mkCtx(99), noop);
|
|
55
|
+
|
|
56
|
+
assert.equal((await storage.get("42"))?.count, 2);
|
|
57
|
+
assert.equal((await storage.get("99"))?.count, 1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("session falls back to initial when nothing stored", async () => {
|
|
61
|
+
const storage = new MemoryStorage<{ count: number }>();
|
|
62
|
+
let seen = -1;
|
|
63
|
+
|
|
64
|
+
const c = new Composer<Context>()
|
|
65
|
+
.install(session({ initial: () => ({ count: 7 }), storage }))
|
|
66
|
+
.use((ctx, next) => {
|
|
67
|
+
seen = ctx.session.count;
|
|
68
|
+
return next();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await entry(c)(mkCtx(1), noop);
|
|
72
|
+
assert.equal(seen, 7);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a custom getKey switches the partition", async () => {
|
|
76
|
+
const storage = new MemoryStorage<{ n: number }>();
|
|
77
|
+
const c = new Composer<Context>()
|
|
78
|
+
.install(session({ initial: () => ({ n: 0 }), storage, getKey: () => "global" }))
|
|
79
|
+
.use((ctx, next) => {
|
|
80
|
+
ctx.session.n++;
|
|
81
|
+
return next();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const mw = entry(c);
|
|
85
|
+
|
|
86
|
+
await mw(mkCtx(1), noop);
|
|
87
|
+
await mw(mkCtx(2), noop);
|
|
88
|
+
|
|
89
|
+
assert.equal((await storage.get("global"))?.n, 2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("keyless update gets a working, non-persisted session", async () => {
|
|
93
|
+
const storage = new MemoryStorage<{ count: number }>();
|
|
94
|
+
let seen = -1;
|
|
95
|
+
|
|
96
|
+
const c = new Composer<Context>()
|
|
97
|
+
.install(session({ initial: () => ({ count: 3 }), storage }))
|
|
98
|
+
.use((ctx, next) => {
|
|
99
|
+
ctx.session.count++;
|
|
100
|
+
seen = ctx.session.count;
|
|
101
|
+
return next();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// an update with no chat (e.g. a poll) → no key
|
|
105
|
+
const keyless = new Context({
|
|
106
|
+
api,
|
|
107
|
+
update: { update_id: 1 } as never,
|
|
108
|
+
updateType: "poll",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await entry(c)(keyless, noop);
|
|
112
|
+
assert.equal(seen, 4); // session was usable
|
|
113
|
+
assert.equal(await storage.get("0"), undefined); // nothing persisted
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a throwing handler leaves storage untouched", async () => {
|
|
117
|
+
const storage = new MemoryStorage<{ count: number }>();
|
|
118
|
+
const c = new Composer<Context>()
|
|
119
|
+
.install(session({ initial: () => ({ count: 0 }), storage }))
|
|
120
|
+
.use((ctx) => {
|
|
121
|
+
ctx.session.count++;
|
|
122
|
+
throw new Error("boom");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await assert.rejects(async () => {
|
|
126
|
+
await entry(c)(mkCtx(5), noop);
|
|
127
|
+
}, /boom/);
|
|
128
|
+
|
|
129
|
+
assert.equal(await storage.get("5"), undefined);
|
|
130
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Context, Plugin } from "@yaebal/core";
|
|
2
|
+
|
|
3
|
+
/** a pluggable session store. implement this to back sessions with a file, redis, etc. */
|
|
4
|
+
export interface StorageAdapter<T> {
|
|
5
|
+
get(key: string): T | undefined | Promise<T | undefined>;
|
|
6
|
+
set(key: string, value: T): unknown | Promise<unknown>;
|
|
7
|
+
delete(key: string): unknown | Promise<unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** defaults to in-memory store. lost on restart — swap for a persistent adapter in production. */
|
|
11
|
+
export class MemoryStorage<T> implements StorageAdapter<T> {
|
|
12
|
+
#map = new Map<string, T>();
|
|
13
|
+
|
|
14
|
+
get(key: string): T | undefined {
|
|
15
|
+
return this.#map.get(key);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
set(key: string, value: T): void {
|
|
19
|
+
this.#map.set(key, value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
delete(key: string): void {
|
|
23
|
+
this.#map.delete(key);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SessionOptions<S> {
|
|
28
|
+
/** build a fresh session when none is stored. required so the type is honest. */
|
|
29
|
+
initial: () => S;
|
|
30
|
+
/** where to persist sessions. defaults to in-memory. */
|
|
31
|
+
storage?: StorageAdapter<S>;
|
|
32
|
+
/** session key for an update. defaults to per-chat (`ctx.chat.id`). */
|
|
33
|
+
getKey?: (ctx: Context) => string | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* session plugin: loads `ctx.session` before handlers run and persists it after.
|
|
38
|
+
* per-chat by default (the grammY convention); override `getKey` for per-user.
|
|
39
|
+
*/
|
|
40
|
+
export function session<S>(options: SessionOptions<S>): Plugin<Context, { session: S }> {
|
|
41
|
+
const { initial } = options;
|
|
42
|
+
|
|
43
|
+
const storage = options.storage ?? new MemoryStorage<S>();
|
|
44
|
+
const getKey = options.getKey ?? ((ctx: Context) => ctx.chat?.id?.toString());
|
|
45
|
+
|
|
46
|
+
return (composer) =>
|
|
47
|
+
composer
|
|
48
|
+
// post-next save. wraps the derive below; runs after handlers, then persists.
|
|
49
|
+
.use(async (ctx, next) => {
|
|
50
|
+
const key = getKey(ctx);
|
|
51
|
+
await next();
|
|
52
|
+
|
|
53
|
+
// no key for this update (e.g. a channel post): the session was a throwaway.
|
|
54
|
+
if (key === undefined) return;
|
|
55
|
+
|
|
56
|
+
// ponytail: writes back unconditionally. add a dirty-check only if a remote
|
|
57
|
+
// storage adapter makes the extra write measurably expensive.
|
|
58
|
+
await storage.set(key, (ctx as unknown as { session: S }).session);
|
|
59
|
+
})
|
|
60
|
+
// load (or initialise) the session before handlers run; the field flows typed.
|
|
61
|
+
.derive(async (ctx) => {
|
|
62
|
+
const key = getKey(ctx);
|
|
63
|
+
const value = key === undefined ? initial() : ((await storage.get(key)) ?? initial());
|
|
64
|
+
|
|
65
|
+
return { session: value };
|
|
66
|
+
});
|
|
67
|
+
}
|