@c9up/ream 0.2.27 → 0.2.28

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 (74) hide show
  1. package/README.md +1 -0
  2. package/dist/Ignitor.d.ts +3 -1
  3. package/dist/Ignitor.d.ts.map +1 -1
  4. package/dist/Ignitor.js +7 -0
  5. package/dist/Ignitor.js.map +1 -1
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/native/generated.d.ts +34 -0
  11. package/dist/native/generated.d.ts.map +1 -1
  12. package/dist/server/Server.d.ts +37 -0
  13. package/dist/server/Server.d.ts.map +1 -1
  14. package/dist/server/Server.js +30 -0
  15. package/dist/server/Server.js.map +1 -1
  16. package/dist/session/Session.d.ts +33 -0
  17. package/dist/session/Session.d.ts.map +1 -1
  18. package/dist/session/Session.js +31 -2
  19. package/dist/session/Session.js.map +1 -1
  20. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  21. package/dist/session/SessionMiddleware.js +24 -4
  22. package/dist/session/SessionMiddleware.js.map +1 -1
  23. package/dist/session/drivers/CookieDriver.d.ts +12 -3
  24. package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
  25. package/dist/session/drivers/CookieDriver.js +20 -6
  26. package/dist/session/drivers/CookieDriver.js.map +1 -1
  27. package/dist/session/drivers/DatabaseDriver.d.ts +2 -0
  28. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  29. package/dist/session/drivers/DatabaseDriver.js +6 -0
  30. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  31. package/dist/session/drivers/FileDriver.d.ts +8 -0
  32. package/dist/session/drivers/FileDriver.d.ts.map +1 -1
  33. package/dist/session/drivers/FileDriver.js +85 -11
  34. package/dist/session/drivers/FileDriver.js.map +1 -1
  35. package/dist/session/drivers/MemoryDriver.d.ts +2 -0
  36. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  37. package/dist/session/drivers/MemoryDriver.js +9 -0
  38. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  39. package/dist/session/drivers/RedisDriver.d.ts +2 -0
  40. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  41. package/dist/session/drivers/RedisDriver.js +6 -0
  42. package/dist/session/drivers/RedisDriver.js.map +1 -1
  43. package/dist/session/errors.d.ts +10 -0
  44. package/dist/session/errors.d.ts.map +1 -1
  45. package/dist/session/errors.js +12 -0
  46. package/dist/session/errors.js.map +1 -1
  47. package/events.darwin-arm64.node +0 -0
  48. package/events.darwin-x64.node +0 -0
  49. package/events.linux-arm64-gnu.node +0 -0
  50. package/events.linux-x64-gnu.node +0 -0
  51. package/events.win32-x64-msvc.node +0 -0
  52. package/index.darwin-arm64.node +0 -0
  53. package/index.darwin-x64.node +0 -0
  54. package/index.linux-arm64-gnu.node +0 -0
  55. package/index.linux-x64-gnu.node +0 -0
  56. package/index.win32-x64-msvc.node +0 -0
  57. package/package.json +1 -1
  58. package/scheduler.darwin-arm64.node +0 -0
  59. package/scheduler.darwin-x64.node +0 -0
  60. package/scheduler.linux-arm64-gnu.node +0 -0
  61. package/scheduler.linux-x64-gnu.node +0 -0
  62. package/scheduler.win32-x64-msvc.node +0 -0
  63. package/src/Ignitor.ts +14 -1
  64. package/src/index.ts +5 -1
  65. package/src/native/generated.ts +38 -0
  66. package/src/server/Server.ts +49 -0
  67. package/src/session/Session.ts +56 -2
  68. package/src/session/SessionMiddleware.ts +23 -4
  69. package/src/session/drivers/CookieDriver.ts +21 -6
  70. package/src/session/drivers/DatabaseDriver.ts +10 -0
  71. package/src/session/drivers/FileDriver.ts +91 -11
  72. package/src/session/drivers/MemoryDriver.ts +9 -0
  73. package/src/session/drivers/RedisDriver.ts +7 -0
  74. package/src/session/errors.ts +17 -0
@@ -16,6 +16,18 @@ export interface FileDriverOptions {
16
16
  location: string
17
17
  }
18
18
 
19
+ /** How long a writer waits for a session's lock before giving up. */
20
+ const LOCK_WAIT_MS = 2000
21
+ /**
22
+ * A lock older than this was left by a process that died holding it. Every
23
+ * operation under the lock is one small file read or write, far below it.
24
+ */
25
+ const LOCK_STALE_MS = 10_000
26
+
27
+ function hasCode(error: unknown, code: string): boolean {
28
+ return error instanceof Error && Reflect.get(error, 'code') === code
29
+ }
30
+
19
31
  export class FileDriver implements SessionDriver {
20
32
  readonly #location: string
21
33
 
@@ -37,6 +49,17 @@ export class FileDriver implements SessionDriver {
37
49
  }
38
50
 
39
51
  async read(sessionId: string): Promise<Record<string, unknown> | null> {
52
+ return this.#read(sessionId, () => this.destroy(sessionId))
53
+ }
54
+
55
+ /**
56
+ * Read, dropping an expired file through `drop`: `destroy()` from outside,
57
+ * a bare removal from inside the lock, which is already held there.
58
+ */
59
+ async #read(
60
+ sessionId: string,
61
+ drop: () => Promise<void>,
62
+ ): Promise<Record<string, unknown> | null> {
40
63
  let raw: string
41
64
  try {
42
65
  raw = await fs.promises.readFile(this.#pathFor(sessionId), 'utf8')
@@ -48,7 +71,7 @@ export class FileDriver implements SessionDriver {
48
71
  if (typeof parsed !== 'object' || parsed === null) return null
49
72
  const entry = parsed as { expiresAt?: number; data?: Record<string, unknown> }
50
73
  if (typeof entry.expiresAt === 'number' && entry.expiresAt < Date.now()) {
51
- await this.destroy(sessionId)
74
+ await drop()
52
75
  return null
53
76
  }
54
77
  return entry.data ?? {}
@@ -60,6 +83,43 @@ export class FileDriver implements SessionDriver {
60
83
  }
61
84
 
62
85
  async write(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void> {
86
+ await this.#locked(sessionId, () => this.#replace(sessionId, data, ttl))
87
+ }
88
+
89
+ /**
90
+ * Written only while the file is still there. A filesystem has no
91
+ * conditional write, so every change to a session's file — this, `write`,
92
+ * `destroy` and `touch` — runs under that session's lock: a `destroy` from a
93
+ * logout cannot land between the check and the write, in this process or in
94
+ * another one sharing the directory.
95
+ */
96
+ async update(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<boolean> {
97
+ return this.#locked(sessionId, async () => {
98
+ if ((await this.#read(sessionId, () => this.#remove(sessionId))) === null) return false
99
+ await this.#replace(sessionId, data, ttl)
100
+ return true
101
+ })
102
+ }
103
+
104
+ async destroy(sessionId: string): Promise<void> {
105
+ await this.#locked(sessionId, () => this.#remove(sessionId))
106
+ }
107
+
108
+ async #remove(sessionId: string): Promise<void> {
109
+ await fs.promises.rm(this.#pathFor(sessionId), { force: true })
110
+ }
111
+
112
+ async touch(sessionId: string, ttl: number): Promise<void> {
113
+ await this.#locked(sessionId, async () => {
114
+ const data = await this.#read(sessionId, () => this.#remove(sessionId))
115
+ // Nothing stored: there is no expiry to slide, and writing here would
116
+ // create the very session that was not there.
117
+ if (data === null || Object.keys(data).length === 0) return
118
+ await this.#replace(sessionId, data, ttl)
119
+ })
120
+ }
121
+
122
+ async #replace(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void> {
63
123
  const file = this.#pathFor(sessionId)
64
124
  const payload = JSON.stringify({ expiresAt: Date.now() + ttl * 1000, data })
65
125
  // Written to a temporary neighbour then renamed: a crash mid-write would
@@ -70,15 +130,35 @@ export class FileDriver implements SessionDriver {
70
130
  await fs.promises.rename(temp, file)
71
131
  }
72
132
 
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
- // Nothing stored: there is no expiry to slide, and writing here would
80
- // create the very session that was not there.
81
- if (data === null || Object.keys(data).length === 0) return
82
- await this.write(sessionId, data, ttl)
133
+ /**
134
+ * Run `fn` holding the session's lock: a directory beside its file, which
135
+ * `mkdir` creates atomically or refuses — the one exclusive primitive every
136
+ * filesystem gives, across processes too.
137
+ */
138
+ async #locked<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
139
+ const lock = `${this.#pathFor(sessionId)}.lock`
140
+ const deadline = Date.now() + LOCK_WAIT_MS
141
+ for (;;) {
142
+ try {
143
+ await fs.promises.mkdir(lock)
144
+ break
145
+ } catch (error) {
146
+ if (!hasCode(error, 'EEXIST')) throw error
147
+ const held = await fs.promises.stat(lock).catch(() => null)
148
+ if (held !== null && Date.now() - held.mtimeMs > LOCK_STALE_MS) {
149
+ await fs.promises.rm(lock, { recursive: true, force: true })
150
+ continue
151
+ }
152
+ if (Date.now() > deadline) {
153
+ throw new Error(`Session file is locked: ${lock}`)
154
+ }
155
+ await new Promise((resolve) => setTimeout(resolve, 5))
156
+ }
157
+ }
158
+ try {
159
+ return await fn()
160
+ } finally {
161
+ await fs.promises.rmdir(lock).catch(() => {})
162
+ }
83
163
  }
84
164
  }
@@ -57,6 +57,15 @@ export class MemoryDriver implements SessionDriverWithTagging {
57
57
  }
58
58
  }
59
59
 
60
+ /** Atomic by construction: nothing else runs between the check and the set. */
61
+ async update(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<boolean> {
62
+ const entry = this.#store.get(sessionId)
63
+ if (!entry || entry.expiresAt < Date.now()) return false
64
+ this.#store.delete(sessionId)
65
+ this.#store.set(sessionId, { data, expiresAt: Date.now() + ttl * 1000 })
66
+ return true
67
+ }
68
+
60
69
  async destroy(sessionId: string): Promise<void> {
61
70
  this.#store.delete(sessionId)
62
71
  // Drop the tags too: a destroyed session left in the index would be
@@ -142,6 +142,13 @@ export class RedisDriver implements SessionDriverWithTagging {
142
142
  await client.set(this.#key(sessionId), JSON.stringify(data), 'EX', ttl)
143
143
  }
144
144
 
145
+ /** `XX` makes the existence check and the write one command. */
146
+ async update(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<boolean> {
147
+ const client = await this.#client()
148
+ const reply = await client.set(this.#key(sessionId), JSON.stringify(data), 'EX', ttl, 'XX')
149
+ return reply !== null
150
+ }
151
+
145
152
  async destroy(sessionId: string): Promise<void> {
146
153
  const client = await this.#client()
147
154
  await client.del(this.#key(sessionId))
@@ -25,6 +25,23 @@ export class E_SESSION_NOT_READY extends Exception {
25
25
  }
26
26
  }
27
27
 
28
+ /**
29
+ * A store with no `update()`: it cannot write a session back only while its
30
+ * entry still exists, so a request running across a logout could sign the
31
+ * user in again. Refused when the store is attached, not at the first race.
32
+ */
33
+ export class E_SESSION_STORE_NOT_CONDITIONAL extends Exception {
34
+ static override status = 500
35
+ static override code = 'E_SESSION_STORE_NOT_CONDITIONAL'
36
+
37
+ constructor() {
38
+ super(
39
+ 'This session store has no update(): it must write a session only over an entry that still exists, in one step. See "Writing a session store".',
40
+ { status: 500, code: 'E_SESSION_STORE_NOT_CONDITIONAL' },
41
+ )
42
+ }
43
+ }
44
+
28
45
  /** The configured store cannot tag sessions (cookie and file stores cannot). */
29
46
  export class E_SESSION_TAGGING_NOT_SUPPORTED extends Exception {
30
47
  static override status = 500