@c9up/ream 0.1.32 → 0.1.33

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.
Files changed (188) hide show
  1. package/dist/Application.d.ts +27 -0
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +29 -0
  4. package/dist/Application.js.map +1 -1
  5. package/dist/ErrorBoundary.d.ts.map +1 -1
  6. package/dist/ErrorBoundary.js +13 -1
  7. package/dist/ErrorBoundary.js.map +1 -1
  8. package/dist/HttpKernel.d.ts +8 -0
  9. package/dist/HttpKernel.d.ts.map +1 -1
  10. package/dist/HttpKernel.js +35 -12
  11. package/dist/HttpKernel.js.map +1 -1
  12. package/dist/Ignitor.d.ts +8 -0
  13. package/dist/Ignitor.d.ts.map +1 -1
  14. package/dist/Ignitor.js +39 -0
  15. package/dist/Ignitor.js.map +1 -1
  16. package/dist/bodyparser/BodyParserMiddleware.d.ts +11 -4
  17. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  18. package/dist/bodyparser/BodyParserMiddleware.js +55 -43
  19. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  20. package/dist/bodyparser/qsParse.d.ts +52 -0
  21. package/dist/bodyparser/qsParse.d.ts.map +1 -0
  22. package/dist/bodyparser/qsParse.js +161 -0
  23. package/dist/bodyparser/qsParse.js.map +1 -0
  24. package/dist/bootstrap/hyper-server.d.ts.map +1 -1
  25. package/dist/bootstrap/hyper-server.js +2 -8
  26. package/dist/bootstrap/hyper-server.js.map +1 -1
  27. package/dist/container/Container.d.ts +41 -0
  28. package/dist/container/Container.d.ts.map +1 -1
  29. package/dist/container/Container.js +193 -26
  30. package/dist/container/Container.js.map +1 -1
  31. package/dist/container/ContainerResolver.d.ts +46 -0
  32. package/dist/container/ContainerResolver.d.ts.map +1 -0
  33. package/dist/container/ContainerResolver.js +60 -0
  34. package/dist/container/ContainerResolver.js.map +1 -0
  35. package/dist/container/ContextualBindingsBuilder.d.ts +20 -0
  36. package/dist/container/ContextualBindingsBuilder.d.ts.map +1 -0
  37. package/dist/container/ContextualBindingsBuilder.js +31 -0
  38. package/dist/container/ContextualBindingsBuilder.js.map +1 -0
  39. package/dist/container/types.d.ts +9 -6
  40. package/dist/container/types.d.ts.map +1 -1
  41. package/dist/decorators/Service.d.ts +1 -5
  42. package/dist/decorators/Service.d.ts.map +1 -1
  43. package/dist/decorators/Service.js +10 -1
  44. package/dist/decorators/Service.js.map +1 -1
  45. package/dist/events/Emitter.d.ts +66 -6
  46. package/dist/events/Emitter.d.ts.map +1 -1
  47. package/dist/events/Emitter.js +165 -28
  48. package/dist/events/Emitter.js.map +1 -1
  49. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  50. package/dist/graphql/GraphQLEngine.js +5 -5
  51. package/dist/graphql/GraphQLEngine.js.map +1 -1
  52. package/dist/helpers/duration.d.ts +32 -0
  53. package/dist/helpers/duration.d.ts.map +1 -0
  54. package/dist/helpers/duration.js +74 -0
  55. package/dist/helpers/duration.js.map +1 -0
  56. package/dist/helpers/napi-loader.d.ts +10 -1
  57. package/dist/helpers/napi-loader.d.ts.map +1 -1
  58. package/dist/helpers/napi-loader.js +18 -3
  59. package/dist/helpers/napi-loader.js.map +1 -1
  60. package/dist/helpers/types.d.ts +1 -1
  61. package/dist/helpers/types.d.ts.map +1 -1
  62. package/dist/http/Exception.d.ts.map +1 -1
  63. package/dist/http/Exception.js +17 -5
  64. package/dist/http/Exception.js.map +1 -1
  65. package/dist/http/HttpContext.d.ts +16 -5
  66. package/dist/http/HttpContext.d.ts.map +1 -1
  67. package/dist/http/HttpContext.js +4 -2
  68. package/dist/http/HttpContext.js.map +1 -1
  69. package/dist/http/RedirectBuilder.d.ts +48 -2
  70. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  71. package/dist/http/RedirectBuilder.js +95 -14
  72. package/dist/http/RedirectBuilder.js.map +1 -1
  73. package/dist/http/Request.d.ts +128 -8
  74. package/dist/http/Request.d.ts.map +1 -1
  75. package/dist/http/Request.js +259 -8
  76. package/dist/http/Request.js.map +1 -1
  77. package/dist/http/Response.d.ts +74 -3
  78. package/dist/http/Response.d.ts.map +1 -1
  79. package/dist/http/Response.js +177 -4
  80. package/dist/http/Response.js.map +1 -1
  81. package/dist/index.d.ts +9 -3
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +5 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/router/Router.d.ts +36 -7
  86. package/dist/router/Router.d.ts.map +1 -1
  87. package/dist/router/Router.js +66 -22
  88. package/dist/router/Router.js.map +1 -1
  89. package/dist/security/CookieSigner.d.ts +35 -4
  90. package/dist/security/CookieSigner.d.ts.map +1 -1
  91. package/dist/security/CookieSigner.js +99 -10
  92. package/dist/security/CookieSigner.js.map +1 -1
  93. package/dist/server/Server.d.ts +2 -3
  94. package/dist/server/Server.d.ts.map +1 -1
  95. package/dist/server/Server.js +30 -13
  96. package/dist/server/Server.js.map +1 -1
  97. package/dist/session/ReadOnlyValuesStore.d.ts +24 -0
  98. package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -0
  99. package/dist/session/ReadOnlyValuesStore.js +45 -0
  100. package/dist/session/ReadOnlyValuesStore.js.map +1 -0
  101. package/dist/session/Session.d.ts +181 -9
  102. package/dist/session/Session.d.ts.map +1 -1
  103. package/dist/session/Session.js +284 -33
  104. package/dist/session/Session.js.map +1 -1
  105. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  106. package/dist/session/SessionMiddleware.js +87 -33
  107. package/dist/session/SessionMiddleware.js.map +1 -1
  108. package/dist/session/drivers/DatabaseDriver.d.ts +66 -0
  109. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -0
  110. package/dist/session/drivers/DatabaseDriver.js +132 -0
  111. package/dist/session/drivers/DatabaseDriver.js.map +1 -0
  112. package/dist/session/drivers/FileDriver.d.ts +22 -0
  113. package/dist/session/drivers/FileDriver.d.ts.map +1 -0
  114. package/dist/session/drivers/FileDriver.js +66 -0
  115. package/dist/session/drivers/FileDriver.js.map +1 -0
  116. package/dist/session/drivers/MemoryDriver.d.ts +10 -2
  117. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  118. package/dist/session/drivers/MemoryDriver.js +70 -0
  119. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  120. package/dist/session/drivers/RedisDriver.d.ts +10 -2
  121. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  122. package/dist/session/drivers/RedisDriver.js +58 -0
  123. package/dist/session/drivers/RedisDriver.js.map +1 -1
  124. package/dist/session/templateTags.d.ts +42 -0
  125. package/dist/session/templateTags.d.ts.map +1 -0
  126. package/dist/session/templateTags.js +101 -0
  127. package/dist/session/templateTags.js.map +1 -0
  128. package/dist/testing/RequestBuilder.d.ts +38 -1
  129. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  130. package/dist/testing/RequestBuilder.js +40 -1
  131. package/dist/testing/RequestBuilder.js.map +1 -1
  132. package/dist/testing/TestClient.d.ts +3 -1
  133. package/dist/testing/TestClient.d.ts.map +1 -1
  134. package/dist/testing/TestClient.js +3 -1
  135. package/dist/testing/TestClient.js.map +1 -1
  136. package/dist/types/helpers.d.ts +1 -1
  137. package/dist/types/helpers.d.ts.map +1 -1
  138. package/dist/utils/Macroable.d.ts +14 -5
  139. package/dist/utils/Macroable.d.ts.map +1 -1
  140. package/dist/utils/Macroable.js +20 -18
  141. package/dist/utils/Macroable.js.map +1 -1
  142. package/events.win32-x64-msvc.node +0 -0
  143. package/index.win32-x64-msvc.node +0 -0
  144. package/package.json +1 -1
  145. package/scheduler.win32-x64-msvc.node +0 -0
  146. package/src/Application.ts +39 -0
  147. package/src/ErrorBoundary.ts +13 -1
  148. package/src/HttpKernel.ts +45 -19
  149. package/src/Ignitor.ts +39 -0
  150. package/src/bodyparser/BodyParserMiddleware.ts +66 -44
  151. package/src/bodyparser/qsParse.ts +179 -0
  152. package/src/bootstrap/hyper-server.ts +2 -9
  153. package/src/container/Container.ts +265 -25
  154. package/src/container/ContainerResolver.ts +84 -0
  155. package/src/container/ContextualBindingsBuilder.ts +41 -0
  156. package/src/container/types.ts +11 -7
  157. package/src/decorators/Service.ts +16 -3
  158. package/src/events/Emitter.ts +200 -25
  159. package/src/graphql/GraphQLEngine.ts +7 -8
  160. package/src/helpers/duration.ts +71 -0
  161. package/src/helpers/napi-loader.ts +19 -3
  162. package/src/helpers/types.ts +4 -1
  163. package/src/http/Exception.ts +17 -5
  164. package/src/http/HttpContext.ts +20 -5
  165. package/src/http/RedirectBuilder.ts +126 -11
  166. package/src/http/Request.ts +283 -13
  167. package/src/http/Response.ts +213 -6
  168. package/src/index.ts +17 -3
  169. package/src/router/Router.ts +98 -33
  170. package/src/security/CookieSigner.ts +102 -10
  171. package/src/server/Server.ts +36 -16
  172. package/src/session/ReadOnlyValuesStore.ts +52 -0
  173. package/src/session/Session.ts +364 -37
  174. package/src/session/SessionMiddleware.ts +93 -30
  175. package/src/session/drivers/DatabaseDriver.ts +184 -0
  176. package/src/session/drivers/FileDriver.ts +82 -0
  177. package/src/session/drivers/MemoryDriver.ts +75 -2
  178. package/src/session/drivers/RedisDriver.ts +77 -2
  179. package/src/session/templateTags.ts +124 -0
  180. package/src/testing/RequestBuilder.ts +76 -0
  181. package/src/testing/TestClient.ts +10 -2
  182. package/src/types/helpers.ts +4 -1
  183. package/src/utils/Macroable.ts +23 -8
  184. package/dist/utils/inflect.d.ts +0 -17
  185. package/dist/utils/inflect.d.ts.map +0 -1
  186. package/dist/utils/inflect.js +0 -32
  187. package/dist/utils/inflect.js.map +0 -1
  188. 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 { SessionDriver } from '../Session.js'
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
- export class MemoryDriver implements SessionDriver {
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 { SessionDriver } from '../Session.js'
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 SessionDriver {
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
+ }