@c9up/ream 0.1.32 → 0.1.34
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/dist/Application.d.ts +27 -0
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +29 -0
- package/dist/Application.js.map +1 -1
- package/dist/ConfigLoader.d.ts +4 -0
- package/dist/ConfigLoader.d.ts.map +1 -1
- package/dist/ConfigLoader.js +0 -8
- package/dist/ConfigLoader.js.map +1 -1
- package/dist/ErrorBoundary.d.ts.map +1 -1
- package/dist/ErrorBoundary.js +13 -1
- package/dist/ErrorBoundary.js.map +1 -1
- package/dist/HttpKernel.d.ts +8 -0
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +35 -12
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts +8 -0
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +39 -0
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.d.ts +11 -4
- package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.js +55 -43
- package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
- package/dist/bodyparser/qsParse.d.ts +52 -0
- package/dist/bodyparser/qsParse.d.ts.map +1 -0
- package/dist/bodyparser/qsParse.js +161 -0
- package/dist/bodyparser/qsParse.js.map +1 -0
- package/dist/bootstrap/hyper-server.d.ts.map +1 -1
- package/dist/bootstrap/hyper-server.js +2 -8
- package/dist/bootstrap/hyper-server.js.map +1 -1
- package/dist/container/Container.d.ts +41 -0
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +193 -26
- package/dist/container/Container.js.map +1 -1
- package/dist/container/ContainerResolver.d.ts +46 -0
- package/dist/container/ContainerResolver.d.ts.map +1 -0
- package/dist/container/ContainerResolver.js +60 -0
- package/dist/container/ContainerResolver.js.map +1 -0
- package/dist/container/ContextualBindingsBuilder.d.ts +20 -0
- package/dist/container/ContextualBindingsBuilder.d.ts.map +1 -0
- package/dist/container/ContextualBindingsBuilder.js +31 -0
- package/dist/container/ContextualBindingsBuilder.js.map +1 -0
- package/dist/container/types.d.ts +9 -6
- package/dist/container/types.d.ts.map +1 -1
- package/dist/decorators/Service.d.ts +1 -5
- package/dist/decorators/Service.d.ts.map +1 -1
- package/dist/decorators/Service.js +10 -1
- package/dist/decorators/Service.js.map +1 -1
- package/dist/events/Emitter.d.ts +66 -6
- package/dist/events/Emitter.d.ts.map +1 -1
- package/dist/events/Emitter.js +165 -28
- package/dist/events/Emitter.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +5 -5
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/helpers/duration.d.ts +32 -0
- package/dist/helpers/duration.d.ts.map +1 -0
- package/dist/helpers/duration.js +74 -0
- package/dist/helpers/duration.js.map +1 -0
- package/dist/helpers/napi-loader.d.ts +10 -1
- package/dist/helpers/napi-loader.d.ts.map +1 -1
- package/dist/helpers/napi-loader.js +18 -3
- package/dist/helpers/napi-loader.js.map +1 -1
- package/dist/helpers/types.d.ts +1 -1
- package/dist/helpers/types.d.ts.map +1 -1
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +17 -5
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/HttpContext.d.ts +24 -5
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js +12 -2
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/RedirectBuilder.d.ts +48 -2
- package/dist/http/RedirectBuilder.d.ts.map +1 -1
- package/dist/http/RedirectBuilder.js +95 -14
- package/dist/http/RedirectBuilder.js.map +1 -1
- package/dist/http/Request.d.ts +143 -8
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +241 -21
- package/dist/http/Request.js.map +1 -1
- package/dist/http/Response.d.ts +74 -3
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +177 -4
- package/dist/http/Response.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/router/Router.d.ts +36 -7
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +66 -22
- package/dist/router/Router.js.map +1 -1
- package/dist/security/CookieSigner.d.ts +35 -4
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +99 -10
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/server/Server.d.ts +2 -3
- package/dist/server/Server.d.ts.map +1 -1
- package/dist/server/Server.js +30 -13
- package/dist/server/Server.js.map +1 -1
- package/dist/session/ReadOnlyValuesStore.d.ts +24 -0
- package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -0
- package/dist/session/ReadOnlyValuesStore.js +45 -0
- package/dist/session/ReadOnlyValuesStore.js.map +1 -0
- package/dist/session/Session.d.ts +181 -9
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js +284 -33
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +87 -33
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/drivers/DatabaseDriver.d.ts +66 -0
- package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -0
- package/dist/session/drivers/DatabaseDriver.js +132 -0
- package/dist/session/drivers/DatabaseDriver.js.map +1 -0
- package/dist/session/drivers/FileDriver.d.ts +22 -0
- package/dist/session/drivers/FileDriver.d.ts.map +1 -0
- package/dist/session/drivers/FileDriver.js +66 -0
- package/dist/session/drivers/FileDriver.js.map +1 -0
- package/dist/session/drivers/MemoryDriver.d.ts +10 -2
- package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +70 -0
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/session/drivers/RedisDriver.d.ts +10 -2
- package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/session/drivers/RedisDriver.js +58 -0
- package/dist/session/drivers/RedisDriver.js.map +1 -1
- package/dist/session/templateTags.d.ts +42 -0
- package/dist/session/templateTags.d.ts.map +1 -0
- package/dist/session/templateTags.js +101 -0
- package/dist/session/templateTags.js.map +1 -0
- package/dist/testing/RequestBuilder.d.ts +38 -1
- package/dist/testing/RequestBuilder.d.ts.map +1 -1
- package/dist/testing/RequestBuilder.js +40 -1
- package/dist/testing/RequestBuilder.js.map +1 -1
- package/dist/testing/TestClient.d.ts +3 -1
- package/dist/testing/TestClient.d.ts.map +1 -1
- package/dist/testing/TestClient.js +3 -1
- package/dist/testing/TestClient.js.map +1 -1
- package/dist/types/helpers.d.ts +1 -1
- package/dist/types/helpers.d.ts.map +1 -1
- package/dist/utils/Macroable.d.ts +14 -5
- package/dist/utils/Macroable.d.ts.map +1 -1
- package/dist/utils/Macroable.js +20 -18
- package/dist/utils/Macroable.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Application.ts +39 -0
- package/src/ConfigLoader.ts +6 -0
- package/src/ErrorBoundary.ts +13 -1
- package/src/HttpKernel.ts +45 -19
- package/src/Ignitor.ts +39 -0
- package/src/bodyparser/BodyParserMiddleware.ts +66 -44
- package/src/bodyparser/qsParse.ts +179 -0
- package/src/bootstrap/hyper-server.ts +2 -9
- package/src/container/Container.ts +265 -25
- package/src/container/ContainerResolver.ts +84 -0
- package/src/container/ContextualBindingsBuilder.ts +41 -0
- package/src/container/types.ts +11 -7
- package/src/decorators/Service.ts +16 -3
- package/src/events/Emitter.ts +200 -25
- package/src/graphql/GraphQLEngine.ts +7 -8
- package/src/helpers/duration.ts +71 -0
- package/src/helpers/napi-loader.ts +19 -3
- package/src/helpers/types.ts +4 -1
- package/src/http/Exception.ts +17 -5
- package/src/http/HttpContext.ts +28 -5
- package/src/http/RedirectBuilder.ts +126 -11
- package/src/http/Request.ts +303 -13
- package/src/http/Response.ts +213 -6
- package/src/index.ts +17 -3
- package/src/router/Router.ts +98 -33
- package/src/security/CookieSigner.ts +102 -10
- package/src/server/Server.ts +36 -16
- package/src/session/ReadOnlyValuesStore.ts +52 -0
- package/src/session/Session.ts +364 -37
- package/src/session/SessionMiddleware.ts +93 -30
- package/src/session/drivers/DatabaseDriver.ts +184 -0
- package/src/session/drivers/FileDriver.ts +82 -0
- package/src/session/drivers/MemoryDriver.ts +75 -2
- package/src/session/drivers/RedisDriver.ts +77 -2
- package/src/session/templateTags.ts +124 -0
- package/src/testing/RequestBuilder.ts +76 -0
- package/src/testing/TestClient.ts +10 -2
- package/src/types/helpers.ts +4 -1
- package/src/utils/Macroable.ts +23 -8
- package/dist/utils/inflect.d.ts +0 -17
- package/dist/utils/inflect.d.ts.map +0 -1
- package/dist/utils/inflect.js +0 -32
- package/dist/utils/inflect.js.map +0 -1
- package/src/utils/inflect.ts +0 -29
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { SessionDriverWithTagging, TaggedSession } from '../Session.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The little a session store needs of a database connection.
|
|
5
|
+
*
|
|
6
|
+
* Structural on purpose: ream must not import atlas to store a session, so any
|
|
7
|
+
* connection exposing these two methods works — atlas, a test double, or an
|
|
8
|
+
* app's own wrapper.
|
|
9
|
+
*/
|
|
10
|
+
export interface SessionDbConnection {
|
|
11
|
+
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>
|
|
12
|
+
execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DatabaseDriverOptions {
|
|
16
|
+
connection: SessionDbConnection
|
|
17
|
+
/** Table holding the sessions. Default `sessions`. */
|
|
18
|
+
tableName?: string
|
|
19
|
+
/**
|
|
20
|
+
* Percent chance that a write also sweeps expired rows (AdonisJS
|
|
21
|
+
* `gcProbability`, default 2).
|
|
22
|
+
*
|
|
23
|
+
* Without it nothing collects them: `read()` only drops a row it happens to
|
|
24
|
+
* look at, so a session whose owner never comes back stays in the table
|
|
25
|
+
* forever. `0` turns the sweep off, for a deployment that prunes on a
|
|
26
|
+
* schedule instead — see {@link DatabaseDriver.prune}.
|
|
27
|
+
*/
|
|
28
|
+
gcProbability?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** AdonisJS's default: two writes in a hundred pay for the cleanup. */
|
|
32
|
+
const DEFAULT_GC_PROBABILITY = 2
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Database-backed sessions (AdonisJS `database` store).
|
|
36
|
+
*
|
|
37
|
+
* The fit is several app instances sharing one database and no Redis: every
|
|
38
|
+
* instance reads the same sessions, which neither the memory nor the file store
|
|
39
|
+
* can do.
|
|
40
|
+
*
|
|
41
|
+
* The table it expects:
|
|
42
|
+
*
|
|
43
|
+
* id varchar primary key
|
|
44
|
+
* data text
|
|
45
|
+
* expires_at bigint -- epoch ms
|
|
46
|
+
* user_id varchar null -- session tagging; nullable, only set at login
|
|
47
|
+
*/
|
|
48
|
+
export class DatabaseDriver implements SessionDriverWithTagging {
|
|
49
|
+
readonly #db: SessionDbConnection
|
|
50
|
+
readonly #table: string
|
|
51
|
+
readonly #gcProbability: number
|
|
52
|
+
|
|
53
|
+
constructor(options: DatabaseDriverOptions) {
|
|
54
|
+
this.#db = options.connection
|
|
55
|
+
const gc = options.gcProbability ?? DEFAULT_GC_PROBABILITY
|
|
56
|
+
if (!Number.isFinite(gc) || gc < 0 || gc > 100) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Session gcProbability must be a percentage between 0 and 100, got ${String(options.gcProbability)}.`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
this.#gcProbability = gc
|
|
62
|
+
const table = options.tableName ?? 'sessions'
|
|
63
|
+
// The table name is concatenated into the SQL (it cannot be a bind
|
|
64
|
+
// parameter), so it is restricted to an identifier rather than trusted.
|
|
65
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Session table name "${table}" is not a plain identifier — letters, digits and underscores only.`,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
this.#table = table
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async read(sessionId: string): Promise<Record<string, unknown>> {
|
|
74
|
+
const rows = await this.#db.query<{ data: string; expires_at: number | string }>(
|
|
75
|
+
`SELECT data, expires_at FROM ${this.#table} WHERE id = ?`,
|
|
76
|
+
[sessionId],
|
|
77
|
+
)
|
|
78
|
+
const row = rows[0]
|
|
79
|
+
if (!row) return {}
|
|
80
|
+
if (Number(row.expires_at) < Date.now()) {
|
|
81
|
+
await this.destroy(sessionId)
|
|
82
|
+
return {}
|
|
83
|
+
}
|
|
84
|
+
return parseData(row.data)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async write(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void> {
|
|
88
|
+
const payload = JSON.stringify(data)
|
|
89
|
+
const expiresAt = Date.now() + ttl * 1000
|
|
90
|
+
// UPDATE-then-INSERT rather than a dialect-specific upsert: `ON CONFLICT`
|
|
91
|
+
// and `ON DUPLICATE KEY` are spelled differently by each engine, and this
|
|
92
|
+
// store has to work on all of them.
|
|
93
|
+
const updated = await this.#db.execute(
|
|
94
|
+
`UPDATE ${this.#table} SET data = ?, expires_at = ? WHERE id = ?`,
|
|
95
|
+
[payload, expiresAt, sessionId],
|
|
96
|
+
)
|
|
97
|
+
if (updated.rowsAffected > 0) {
|
|
98
|
+
await this.#collectGarbage()
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
await this.#db.execute(`INSERT INTO ${this.#table} (id, data, expires_at) VALUES (?, ?, ?)`, [
|
|
102
|
+
sessionId,
|
|
103
|
+
payload,
|
|
104
|
+
expiresAt,
|
|
105
|
+
])
|
|
106
|
+
await this.#collectGarbage()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Sweep expired rows on a fraction of writes (AdonisJS `#collectGarbage`).
|
|
111
|
+
*
|
|
112
|
+
* Best-effort: a failed sweep must not fail the request that happened to draw
|
|
113
|
+
* the short straw. What it did not remove, the next write that does will.
|
|
114
|
+
*/
|
|
115
|
+
async #collectGarbage(): Promise<void> {
|
|
116
|
+
if (this.#gcProbability <= 0) return
|
|
117
|
+
if (Math.random() * 100 >= this.#gcProbability) return
|
|
118
|
+
try {
|
|
119
|
+
await this.prune()
|
|
120
|
+
} catch {
|
|
121
|
+
// See above — the session was written, which is what the request needed.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async destroy(sessionId: string): Promise<void> {
|
|
126
|
+
await this.#db.execute(`DELETE FROM ${this.#table} WHERE id = ?`, [sessionId])
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async touch(sessionId: string, ttl: number): Promise<void> {
|
|
130
|
+
await this.#db.execute(`UPDATE ${this.#table} SET expires_at = ? WHERE id = ?`, [
|
|
131
|
+
Date.now() + ttl * 1000,
|
|
132
|
+
sessionId,
|
|
133
|
+
])
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Associate the session with a user (AdonisJS's database store does the same:
|
|
138
|
+
* a nullable `user_id` column, set at login).
|
|
139
|
+
*/
|
|
140
|
+
async tag(sessionId: string, userId: string | number): Promise<void> {
|
|
141
|
+
await this.#db.execute(`UPDATE ${this.#table} SET user_id = ? WHERE id = ?`, [
|
|
142
|
+
String(userId),
|
|
143
|
+
sessionId,
|
|
144
|
+
])
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Drop the association. Scoped to the user id as well as the session, so a
|
|
149
|
+
* logout cannot clear a tag another user's session happens to hold.
|
|
150
|
+
*/
|
|
151
|
+
async untag(sessionId: string, userId: string | number): Promise<void> {
|
|
152
|
+
await this.#db.execute(
|
|
153
|
+
`UPDATE ${this.#table} SET user_id = NULL WHERE id = ? AND user_id = ?`,
|
|
154
|
+
[sessionId, String(userId)],
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Every non-expired session this user holds — their active devices. */
|
|
159
|
+
async tagged(userId: string | number): Promise<TaggedSession[]> {
|
|
160
|
+
const rows = await this.#db.query<{ id: string; data: string }>(
|
|
161
|
+
`SELECT id, data FROM ${this.#table} WHERE user_id = ? AND expires_at >= ?`,
|
|
162
|
+
[String(userId), Date.now()],
|
|
163
|
+
)
|
|
164
|
+
return rows.map((row) => ({ id: row.id, data: parseData(row.data) }))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Delete every expired row — for a scheduled job, since nothing else does. */
|
|
168
|
+
async prune(): Promise<void> {
|
|
169
|
+
await this.#db.execute(`DELETE FROM ${this.#table} WHERE expires_at < ?`, [Date.now()])
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A stored payload back into an object. An unreadable row is an absent session
|
|
175
|
+
* rather than a half-parsed object handed to the app.
|
|
176
|
+
*/
|
|
177
|
+
function parseData(raw: string): Record<string, unknown> {
|
|
178
|
+
try {
|
|
179
|
+
const parsed: unknown = JSON.parse(raw)
|
|
180
|
+
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : {}
|
|
181
|
+
} catch {
|
|
182
|
+
return {}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import type { SessionDriver } from '../Session.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* File-backed sessions (AdonisJS `file` store) — one file per session id.
|
|
8
|
+
*
|
|
9
|
+
* The fit is a single machine that must survive a restart without running a
|
|
10
|
+
* Redis: the data outlives the process, which the memory store cannot offer.
|
|
11
|
+
* It does NOT fit several machines, where each one would hold a different half
|
|
12
|
+
* of the sessions.
|
|
13
|
+
*/
|
|
14
|
+
export interface FileDriverOptions {
|
|
15
|
+
/** Directory the session files live in. Created if missing. */
|
|
16
|
+
location: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class FileDriver implements SessionDriver {
|
|
20
|
+
readonly #location: string
|
|
21
|
+
|
|
22
|
+
constructor(options: FileDriverOptions) {
|
|
23
|
+
this.#location = path.resolve(options.location)
|
|
24
|
+
fs.mkdirSync(this.#location, { recursive: true })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The file backing a session id.
|
|
29
|
+
*
|
|
30
|
+
* The id is HASHED, never used as a filename: it arrives from a cookie, so a
|
|
31
|
+
* value like `../../etc/passwd` would otherwise choose the path. A hash also
|
|
32
|
+
* keeps the name a fixed, filesystem-safe length whatever the id looks like.
|
|
33
|
+
*/
|
|
34
|
+
#pathFor(sessionId: string): string {
|
|
35
|
+
const name = createHash('sha256').update(sessionId).digest('hex')
|
|
36
|
+
return path.join(this.#location, `${name}.json`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async read(sessionId: string): Promise<Record<string, unknown>> {
|
|
40
|
+
let raw: string
|
|
41
|
+
try {
|
|
42
|
+
raw = await fs.promises.readFile(this.#pathFor(sessionId), 'utf8')
|
|
43
|
+
} catch {
|
|
44
|
+
return {}
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(raw)
|
|
48
|
+
if (typeof parsed !== 'object' || parsed === null) return {}
|
|
49
|
+
const entry = parsed as { expiresAt?: number; data?: Record<string, unknown> }
|
|
50
|
+
if (typeof entry.expiresAt === 'number' && entry.expiresAt < Date.now()) {
|
|
51
|
+
await this.destroy(sessionId)
|
|
52
|
+
return {}
|
|
53
|
+
}
|
|
54
|
+
return entry.data ?? {}
|
|
55
|
+
} catch {
|
|
56
|
+
// A truncated or hand-edited file is a session we cannot trust; treat it
|
|
57
|
+
// as absent rather than handing a half-parsed object to the app.
|
|
58
|
+
return {}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async write(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void> {
|
|
63
|
+
const file = this.#pathFor(sessionId)
|
|
64
|
+
const payload = JSON.stringify({ expiresAt: Date.now() + ttl * 1000, data })
|
|
65
|
+
// Written to a temporary neighbour then renamed: a crash mid-write would
|
|
66
|
+
// otherwise leave a truncated file, and the user would be silently logged
|
|
67
|
+
// out. `rename` within one directory is atomic.
|
|
68
|
+
const temp = `${file}.${process.pid}.tmp`
|
|
69
|
+
await fs.promises.writeFile(temp, payload, { encoding: 'utf8', mode: 0o600 })
|
|
70
|
+
await fs.promises.rename(temp, file)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async destroy(sessionId: string): Promise<void> {
|
|
74
|
+
await fs.promises.rm(this.#pathFor(sessionId), { force: true })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async touch(sessionId: string, ttl: number): Promise<void> {
|
|
78
|
+
const data = await this.read(sessionId)
|
|
79
|
+
if (Object.keys(data).length === 0) return
|
|
80
|
+
await this.write(sessionId, data, ttl)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -1,9 +1,33 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SessionDriverWithTagging, TaggedSession } from '../Session.js'
|
|
2
2
|
|
|
3
|
+
/** Sweep expired entries once the store reaches this size. */
|
|
3
4
|
const SWEEP_THRESHOLD = 1000
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Hard cap on live sessions.
|
|
8
|
+
*
|
|
9
|
+
* The sweep alone only removes EXPIRED entries, so within the TTL window
|
|
10
|
+
* nothing bounded growth: anyone able to request a page could mint sessions
|
|
11
|
+
* until the process ran out of memory, without ever logging in. Past the cap
|
|
12
|
+
* the oldest sessions are dropped — a bounded store that forgets is strictly
|
|
13
|
+
* better than an unbounded one that dies.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_MAX_ENTRIES = 10_000
|
|
16
|
+
|
|
17
|
+
export interface MemoryDriverOptions {
|
|
18
|
+
/** Maximum live sessions kept in memory. Default 10 000. */
|
|
19
|
+
maxEntries?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class MemoryDriver implements SessionDriverWithTagging {
|
|
6
23
|
#store: Map<string, { data: Record<string, unknown>; expiresAt: number }> = new Map()
|
|
24
|
+
/** user id → the sessions tagged with it. */
|
|
25
|
+
#tags: Map<string, Set<string>> = new Map()
|
|
26
|
+
readonly #maxEntries: number
|
|
27
|
+
|
|
28
|
+
constructor(options: MemoryDriverOptions = {}) {
|
|
29
|
+
this.#maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES)
|
|
30
|
+
}
|
|
7
31
|
|
|
8
32
|
async read(sessionId: string): Promise<Record<string, unknown>> {
|
|
9
33
|
const entry = this.#store.get(sessionId)
|
|
@@ -21,11 +45,26 @@ export class MemoryDriver implements SessionDriver {
|
|
|
21
45
|
if (entry.expiresAt < now) this.#store.delete(id)
|
|
22
46
|
}
|
|
23
47
|
}
|
|
48
|
+
// Re-inserting moves the key to the end, so eviction order stays "oldest
|
|
49
|
+
// written first" rather than "oldest ever seen".
|
|
50
|
+
this.#store.delete(sessionId)
|
|
24
51
|
this.#store.set(sessionId, { data, expiresAt: Date.now() + ttl * 1000 })
|
|
52
|
+
// Sweeping may have freed nothing — every session can be live and unexpired.
|
|
53
|
+
while (this.#store.size > this.#maxEntries) {
|
|
54
|
+
const oldest = this.#store.keys().next()
|
|
55
|
+
if (oldest.done) break
|
|
56
|
+
this.#store.delete(oldest.value)
|
|
57
|
+
}
|
|
25
58
|
}
|
|
26
59
|
|
|
27
60
|
async destroy(sessionId: string): Promise<void> {
|
|
28
61
|
this.#store.delete(sessionId)
|
|
62
|
+
// Drop the tags too: a destroyed session left in the index would be
|
|
63
|
+
// reported as an active device forever.
|
|
64
|
+
for (const [userId, ids] of this.#tags) {
|
|
65
|
+
if (!ids.delete(sessionId)) continue
|
|
66
|
+
if (ids.size === 0) this.#tags.delete(userId)
|
|
67
|
+
}
|
|
29
68
|
}
|
|
30
69
|
|
|
31
70
|
async touch(sessionId: string, ttl: number): Promise<void> {
|
|
@@ -34,4 +73,38 @@ export class MemoryDriver implements SessionDriver {
|
|
|
34
73
|
entry.expiresAt = Date.now() + ttl * 1000
|
|
35
74
|
}
|
|
36
75
|
}
|
|
76
|
+
|
|
77
|
+
async tag(sessionId: string, userId: string | number): Promise<void> {
|
|
78
|
+
const key = String(userId)
|
|
79
|
+
const ids = this.#tags.get(key) ?? new Set<string>()
|
|
80
|
+
ids.add(sessionId)
|
|
81
|
+
this.#tags.set(key, ids)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async untag(sessionId: string, userId: string | number): Promise<void> {
|
|
85
|
+
const key = String(userId)
|
|
86
|
+
const ids = this.#tags.get(key)
|
|
87
|
+
if (!ids) return
|
|
88
|
+
ids.delete(sessionId)
|
|
89
|
+
if (ids.size === 0) this.#tags.delete(key)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async tagged(userId: string | number): Promise<TaggedSession[]> {
|
|
93
|
+
const ids = this.#tags.get(String(userId))
|
|
94
|
+
if (!ids) return []
|
|
95
|
+
const now = Date.now()
|
|
96
|
+
const sessions: TaggedSession[] = []
|
|
97
|
+
for (const id of [...ids]) {
|
|
98
|
+
const entry = this.#store.get(id)
|
|
99
|
+
// An expired or evicted session is not an active device. Forget it here
|
|
100
|
+
// too, so the index does not grow with entries the store already lost.
|
|
101
|
+
if (!entry || entry.expiresAt < now) {
|
|
102
|
+
ids.delete(id)
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
sessions.push({ id, data: { ...entry.data } })
|
|
106
|
+
}
|
|
107
|
+
if (ids.size === 0) this.#tags.delete(String(userId))
|
|
108
|
+
return sessions
|
|
109
|
+
}
|
|
37
110
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* carries these four commands.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type {
|
|
11
|
+
import type { SessionDriverWithTagging, TaggedSession } from '../Session.js'
|
|
12
12
|
|
|
13
13
|
/** The commands a session needs. Compatible with ioredis and node-redis. */
|
|
14
14
|
export interface SessionRedisClient {
|
|
@@ -16,6 +16,22 @@ export interface SessionRedisClient {
|
|
|
16
16
|
set(key: string, value: string, ...args: unknown[]): Promise<unknown>
|
|
17
17
|
del(key: string | string[]): Promise<number>
|
|
18
18
|
expire(key: string, seconds: number): Promise<number>
|
|
19
|
+
/** Set commands, needed only for session tagging. Every real client has them. */
|
|
20
|
+
sadd?(key: string, ...members: string[]): Promise<number>
|
|
21
|
+
srem?(key: string, ...members: string[]): Promise<number>
|
|
22
|
+
smembers?(key: string): Promise<string[]>
|
|
23
|
+
exists?(key: string): Promise<number>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The set commands session tagging needs, over and above the base four. */
|
|
27
|
+
const SET_COMMANDS = ['sadd', 'srem', 'smembers', 'exists'] as const
|
|
28
|
+
|
|
29
|
+
/** A client that carries them. */
|
|
30
|
+
type TaggingRedisClient = SessionRedisClient &
|
|
31
|
+
Required<Pick<SessionRedisClient, (typeof SET_COMMANDS)[number]>>
|
|
32
|
+
|
|
33
|
+
function hasSetCommands(client: SessionRedisClient): client is TaggingRedisClient {
|
|
34
|
+
return SET_COMMANDS.every((command) => typeof client[command] === 'function')
|
|
19
35
|
}
|
|
20
36
|
|
|
21
37
|
/** Where the client comes from — resolved once, on the first request that needs it. */
|
|
@@ -28,7 +44,7 @@ export interface RedisSessionOptions {
|
|
|
28
44
|
prefix?: string
|
|
29
45
|
}
|
|
30
46
|
|
|
31
|
-
export class RedisDriver implements
|
|
47
|
+
export class RedisDriver implements SessionDriverWithTagging {
|
|
32
48
|
readonly #source: SessionRedisClientSource
|
|
33
49
|
#resolved: SessionRedisClient | undefined
|
|
34
50
|
#pending: Promise<SessionRedisClient> | undefined
|
|
@@ -64,6 +80,35 @@ export class RedisDriver implements SessionDriver {
|
|
|
64
80
|
return `${this.#prefix}${sessionId}`
|
|
65
81
|
}
|
|
66
82
|
|
|
83
|
+
/** Where the set of a user's session ids lives. */
|
|
84
|
+
#tagKey(userId: string | number): string {
|
|
85
|
+
return `${this.#prefix}tag:${userId}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The client, narrowed to one that carries the set commands.
|
|
90
|
+
*
|
|
91
|
+
* They are optional on {@link SessionRedisClient} so a four-command shim
|
|
92
|
+
* still satisfies the store contract. A real client has them; a shim that
|
|
93
|
+
* does not gets told which command is missing, rather than a tag that
|
|
94
|
+
* silently does nothing and a "log out everywhere" that logs nobody out.
|
|
95
|
+
*/
|
|
96
|
+
async #taggingClient(method: string): Promise<TaggingRedisClient> {
|
|
97
|
+
const client = await this.#client()
|
|
98
|
+
const missing = SET_COMMANDS.find((command) => typeof client[command] !== 'function')
|
|
99
|
+
if (missing !== undefined) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`session.${method}() needs the Redis \`${missing.toUpperCase()}\` command, which the configured client does not expose.`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
if (!hasSetCommands(client)) {
|
|
105
|
+
// Unreachable: `missing` already proved every command is there. The guard
|
|
106
|
+
// is what tells the type system so, without asserting it.
|
|
107
|
+
throw new Error(`session.${method}() could not narrow the Redis client.`)
|
|
108
|
+
}
|
|
109
|
+
return client
|
|
110
|
+
}
|
|
111
|
+
|
|
67
112
|
/**
|
|
68
113
|
* Unreadable payloads are treated as an absent session rather than thrown.
|
|
69
114
|
* A corrupt value would otherwise 500 every request carrying that cookie,
|
|
@@ -99,6 +144,36 @@ export class RedisDriver implements SessionDriver {
|
|
|
99
144
|
const client = await this.#client()
|
|
100
145
|
await client.expire(this.#key(sessionId), ttl)
|
|
101
146
|
}
|
|
147
|
+
|
|
148
|
+
async tag(sessionId: string, userId: string | number): Promise<void> {
|
|
149
|
+
const client = await this.#taggingClient('tag')
|
|
150
|
+
await client.sadd(this.#tagKey(userId), sessionId)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async untag(sessionId: string, userId: string | number): Promise<void> {
|
|
154
|
+
const client = await this.#taggingClient('untag')
|
|
155
|
+
await client.srem(this.#tagKey(userId), sessionId)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async tagged(userId: string | number): Promise<TaggedSession[]> {
|
|
159
|
+
const client = await this.#taggingClient('tagged')
|
|
160
|
+
const key = this.#tagKey(userId)
|
|
161
|
+
const ids = await client.smembers(key)
|
|
162
|
+
const sessions: TaggedSession[] = []
|
|
163
|
+
const stale: string[] = []
|
|
164
|
+
for (const id of ids) {
|
|
165
|
+
// A member whose session key expired is not an active device. Redis
|
|
166
|
+
// cannot expire set members individually, so the set is pruned here —
|
|
167
|
+
// otherwise it grows with every session the user ever opened.
|
|
168
|
+
if ((await client.exists(this.#key(id))) === 0) {
|
|
169
|
+
stale.push(id)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
sessions.push({ id, data: await this.read(id) })
|
|
173
|
+
}
|
|
174
|
+
if (stale.length > 0) await client.srem(key, ...stale)
|
|
175
|
+
return sessions
|
|
176
|
+
}
|
|
102
177
|
}
|
|
103
178
|
|
|
104
179
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The flash-message tags AdonisJS's session publishes to templates.
|
|
3
|
+
*
|
|
4
|
+
* A migrated template keeps writing:
|
|
5
|
+
*
|
|
6
|
+
* @error('email')<p>{{ $message }}</p>@enderror
|
|
7
|
+
* @errors<ul>@each(m in $messages)<li>{{ m }}</li>@endeach</ul>@enderrors
|
|
8
|
+
* @inputError('email')@each(m in $messages)<i>{{ m }}</i>@endeach@endinputError
|
|
9
|
+
* @flashMessage('notice')<p>{{ $message }}</p>@endflashMessage
|
|
10
|
+
*
|
|
11
|
+
* Each renders its body only when the message exists, and binds it under the
|
|
12
|
+
* name upstream uses (`$message` / `$messages`) — bound into the BODY's scope,
|
|
13
|
+
* so it does not leak past the tag.
|
|
14
|
+
*
|
|
15
|
+
* The values come from the `flashMessages` store the session middleware shares
|
|
16
|
+
* into `ctx.view`; the tag reads it out of the render scope.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The single surface these tags need of a template engine. */
|
|
20
|
+
export interface TemplateTagEngine {
|
|
21
|
+
registerTag(tag: {
|
|
22
|
+
tagName: string
|
|
23
|
+
block: boolean
|
|
24
|
+
seekable: boolean
|
|
25
|
+
compile(
|
|
26
|
+
parser: unknown,
|
|
27
|
+
buffer: { writeRaw(text: string): void },
|
|
28
|
+
token: {
|
|
29
|
+
properties: { jsArg: string }
|
|
30
|
+
renderBody(locals?: Readonly<Record<string, unknown>>): string | Promise<string>
|
|
31
|
+
evaluate(expression: string): unknown
|
|
32
|
+
},
|
|
33
|
+
): void | Promise<void>
|
|
34
|
+
}): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The bag shape the session shares — matches `ReadOnlyValuesStore`. */
|
|
38
|
+
interface FlashStore {
|
|
39
|
+
get(path: string | readonly string[], defaultValue?: unknown): unknown
|
|
40
|
+
has(path: string | readonly string[]): boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isFlashStore(value: unknown): value is FlashStore {
|
|
44
|
+
return (
|
|
45
|
+
typeof value === 'object' &&
|
|
46
|
+
value !== null &&
|
|
47
|
+
typeof Reflect.get(value, 'get') === 'function' &&
|
|
48
|
+
typeof Reflect.get(value, 'has') === 'function'
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Register `@error`, `@errors`, `@inputError` and `@flashMessage` on `engine`.
|
|
54
|
+
*
|
|
55
|
+
* Safe to call when no session ran: a template rendered outside a request finds
|
|
56
|
+
* no store in scope and every tag renders nothing, rather than throwing.
|
|
57
|
+
*/
|
|
58
|
+
export function registerSessionTemplateTags(engine: TemplateTagEngine): void {
|
|
59
|
+
/** The shared store, or undefined outside a request. */
|
|
60
|
+
const storeOf = (evaluate: (expr: string) => unknown): FlashStore | undefined => {
|
|
61
|
+
const value = evaluate("typeof flashMessages === 'undefined' ? undefined : flashMessages")
|
|
62
|
+
return isFlashStore(value) ? value : undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
engine.registerTag({
|
|
66
|
+
tagName: 'flashMessage',
|
|
67
|
+
block: true,
|
|
68
|
+
seekable: true,
|
|
69
|
+
async compile(_parser, buffer, token) {
|
|
70
|
+
const store = storeOf(token.evaluate)
|
|
71
|
+
if (store === undefined) return
|
|
72
|
+
const key = token.evaluate(token.properties.jsArg)
|
|
73
|
+
if (typeof key !== 'string' || !store.has(key)) return
|
|
74
|
+
buffer.writeRaw(await token.renderBody({ $message: store.get(key) }))
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
engine.registerTag({
|
|
79
|
+
tagName: 'error',
|
|
80
|
+
block: true,
|
|
81
|
+
seekable: true,
|
|
82
|
+
async compile(_parser, buffer, token) {
|
|
83
|
+
const store = storeOf(token.evaluate)
|
|
84
|
+
if (store === undefined) return
|
|
85
|
+
const field = token.evaluate(token.properties.jsArg)
|
|
86
|
+
if (typeof field !== 'string') return
|
|
87
|
+
const path = ['errorsBag', field]
|
|
88
|
+
if (!store.has(path)) return
|
|
89
|
+
buffer.writeRaw(await token.renderBody({ $message: store.get(path) }))
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
engine.registerTag({
|
|
94
|
+
tagName: 'errors',
|
|
95
|
+
block: true,
|
|
96
|
+
// `@errors` takes no argument, exactly as upstream declares it.
|
|
97
|
+
seekable: false,
|
|
98
|
+
async compile(_parser, buffer, token) {
|
|
99
|
+
const store = storeOf(token.evaluate)
|
|
100
|
+
if (store === undefined || !store.has('errorsBag')) return
|
|
101
|
+
buffer.writeRaw(await token.renderBody({ $messages: store.get('errorsBag') }))
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
engine.registerTag({
|
|
106
|
+
tagName: 'inputError',
|
|
107
|
+
block: true,
|
|
108
|
+
seekable: true,
|
|
109
|
+
async compile(_parser, buffer, token) {
|
|
110
|
+
const store = storeOf(token.evaluate)
|
|
111
|
+
if (store === undefined) return
|
|
112
|
+
const field = token.evaluate(token.properties.jsArg)
|
|
113
|
+
if (typeof field !== 'string') return
|
|
114
|
+
const bag = store.get('inputErrorsBag', {})
|
|
115
|
+
const messages =
|
|
116
|
+
typeof bag === 'object' && bag !== null && Object.hasOwn(bag, field)
|
|
117
|
+
? Reflect.get(bag, field)
|
|
118
|
+
: undefined
|
|
119
|
+
// Upstream gates on truthiness, so an empty list renders nothing.
|
|
120
|
+
if (!messages) return
|
|
121
|
+
buffer.writeRaw(await token.renderBody({ $messages: messages }))
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
}
|