@c9up/ream 0.2.26 → 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 (170) hide show
  1. package/README.md +1 -0
  2. package/dist/HttpKernel.d.ts +5 -1
  3. package/dist/HttpKernel.d.ts.map +1 -1
  4. package/dist/HttpKernel.js +6 -6
  5. package/dist/HttpKernel.js.map +1 -1
  6. package/dist/Ignitor.d.ts +3 -1
  7. package/dist/Ignitor.d.ts.map +1 -1
  8. package/dist/Ignitor.js +94 -14
  9. package/dist/Ignitor.js.map +1 -1
  10. package/dist/console/prompts.d.ts.map +1 -1
  11. package/dist/console/prompts.js +34 -46
  12. package/dist/console/prompts.js.map +1 -1
  13. package/dist/console/selection.d.ts +54 -0
  14. package/dist/console/selection.d.ts.map +1 -0
  15. package/dist/console/selection.js +235 -0
  16. package/dist/console/selection.js.map +1 -0
  17. package/dist/dev/fullReload.d.ts +35 -10
  18. package/dist/dev/fullReload.d.ts.map +1 -1
  19. package/dist/dev/fullReload.js +55 -14
  20. package/dist/dev/fullReload.js.map +1 -1
  21. package/dist/dev/hotWatcher.d.ts +19 -3
  22. package/dist/dev/hotWatcher.d.ts.map +1 -1
  23. package/dist/dev/hotWatcher.js +168 -18
  24. package/dist/dev/hotWatcher.js.map +1 -1
  25. package/dist/dev/readyBanner.d.ts +45 -0
  26. package/dist/dev/readyBanner.d.ts.map +1 -0
  27. package/dist/dev/readyBanner.js +72 -0
  28. package/dist/dev/readyBanner.js.map +1 -0
  29. package/dist/dev/shortcuts.d.ts +41 -0
  30. package/dist/dev/shortcuts.d.ts.map +1 -0
  31. package/dist/dev/shortcuts.js +142 -0
  32. package/dist/dev/shortcuts.js.map +1 -0
  33. package/dist/errors/prettyPrintError.d.ts.map +1 -1
  34. package/dist/errors/prettyPrintError.js +5 -8
  35. package/dist/errors/prettyPrintError.js.map +1 -1
  36. package/dist/errors/renderError.d.ts +46 -0
  37. package/dist/errors/renderError.d.ts.map +1 -0
  38. package/dist/errors/renderError.js +197 -0
  39. package/dist/errors/renderError.js.map +1 -0
  40. package/dist/hot.js +12 -2
  41. package/dist/hot.js.map +1 -1
  42. package/dist/index.d.ts +4 -3
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +3 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/native/generated.d.ts +34 -0
  47. package/dist/native/generated.d.ts.map +1 -1
  48. package/dist/profiler/MemoryProfileStore.d.ts +23 -0
  49. package/dist/profiler/MemoryProfileStore.d.ts.map +1 -0
  50. package/dist/profiler/MemoryProfileStore.js +70 -0
  51. package/dist/profiler/MemoryProfileStore.js.map +1 -0
  52. package/dist/profiler/Profiler.d.ts +56 -0
  53. package/dist/profiler/Profiler.d.ts.map +1 -0
  54. package/dist/profiler/Profiler.js +162 -0
  55. package/dist/profiler/Profiler.js.map +1 -0
  56. package/dist/profiler/collectors.d.ts +105 -0
  57. package/dist/profiler/collectors.d.ts.map +1 -0
  58. package/dist/profiler/collectors.js +164 -0
  59. package/dist/profiler/collectors.js.map +1 -0
  60. package/dist/profiler/endpoint.d.ts +27 -0
  61. package/dist/profiler/endpoint.d.ts.map +1 -0
  62. package/dist/profiler/endpoint.js +79 -0
  63. package/dist/profiler/endpoint.js.map +1 -0
  64. package/dist/profiler/index.d.ts +26 -0
  65. package/dist/profiler/index.d.ts.map +1 -0
  66. package/dist/profiler/index.js +41 -0
  67. package/dist/profiler/index.js.map +1 -0
  68. package/dist/profiler/middleware.d.ts +17 -0
  69. package/dist/profiler/middleware.d.ts.map +1 -0
  70. package/dist/profiler/middleware.js +79 -0
  71. package/dist/profiler/middleware.js.map +1 -0
  72. package/dist/profiler/render.d.ts +23 -0
  73. package/dist/profiler/render.d.ts.map +1 -0
  74. package/dist/profiler/render.js +141 -0
  75. package/dist/profiler/render.js.map +1 -0
  76. package/dist/profiler/toolbar.d.ts +34 -0
  77. package/dist/profiler/toolbar.d.ts.map +1 -0
  78. package/dist/profiler/toolbar.js +104 -0
  79. package/dist/profiler/toolbar.js.map +1 -0
  80. package/dist/profiler/types.d.ts +70 -0
  81. package/dist/profiler/types.d.ts.map +1 -0
  82. package/dist/profiler/types.js +11 -0
  83. package/dist/profiler/types.js.map +1 -0
  84. package/dist/router/Router.d.ts +11 -0
  85. package/dist/router/Router.d.ts.map +1 -1
  86. package/dist/router/Router.js +16 -0
  87. package/dist/router/Router.js.map +1 -1
  88. package/dist/server/Server.d.ts +37 -0
  89. package/dist/server/Server.d.ts.map +1 -1
  90. package/dist/server/Server.js +30 -0
  91. package/dist/server/Server.js.map +1 -1
  92. package/dist/session/Session.d.ts +33 -0
  93. package/dist/session/Session.d.ts.map +1 -1
  94. package/dist/session/Session.js +31 -2
  95. package/dist/session/Session.js.map +1 -1
  96. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  97. package/dist/session/SessionMiddleware.js +24 -4
  98. package/dist/session/SessionMiddleware.js.map +1 -1
  99. package/dist/session/drivers/CookieDriver.d.ts +12 -3
  100. package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
  101. package/dist/session/drivers/CookieDriver.js +20 -6
  102. package/dist/session/drivers/CookieDriver.js.map +1 -1
  103. package/dist/session/drivers/DatabaseDriver.d.ts +2 -0
  104. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  105. package/dist/session/drivers/DatabaseDriver.js +6 -0
  106. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  107. package/dist/session/drivers/FileDriver.d.ts +8 -0
  108. package/dist/session/drivers/FileDriver.d.ts.map +1 -1
  109. package/dist/session/drivers/FileDriver.js +85 -11
  110. package/dist/session/drivers/FileDriver.js.map +1 -1
  111. package/dist/session/drivers/MemoryDriver.d.ts +2 -0
  112. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  113. package/dist/session/drivers/MemoryDriver.js +9 -0
  114. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  115. package/dist/session/drivers/RedisDriver.d.ts +2 -0
  116. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  117. package/dist/session/drivers/RedisDriver.js +6 -0
  118. package/dist/session/drivers/RedisDriver.js.map +1 -1
  119. package/dist/session/errors.d.ts +10 -0
  120. package/dist/session/errors.d.ts.map +1 -1
  121. package/dist/session/errors.js +12 -0
  122. package/dist/session/errors.js.map +1 -1
  123. package/events.darwin-arm64.node +0 -0
  124. package/events.darwin-x64.node +0 -0
  125. package/events.linux-arm64-gnu.node +0 -0
  126. package/events.linux-x64-gnu.node +0 -0
  127. package/events.win32-x64-msvc.node +0 -0
  128. package/index.darwin-arm64.node +0 -0
  129. package/index.darwin-x64.node +0 -0
  130. package/index.linux-arm64-gnu.node +0 -0
  131. package/index.linux-x64-gnu.node +0 -0
  132. package/index.win32-x64-msvc.node +0 -0
  133. package/package.json +1 -1
  134. package/scheduler.darwin-arm64.node +0 -0
  135. package/scheduler.darwin-x64.node +0 -0
  136. package/scheduler.linux-arm64-gnu.node +0 -0
  137. package/scheduler.linux-x64-gnu.node +0 -0
  138. package/scheduler.win32-x64-msvc.node +0 -0
  139. package/src/HttpKernel.ts +12 -8
  140. package/src/Ignitor.ts +109 -15
  141. package/src/console/prompts.ts +45 -64
  142. package/src/console/selection.ts +274 -0
  143. package/src/dev/fullReload.ts +57 -15
  144. package/src/dev/hotWatcher.ts +165 -21
  145. package/src/dev/readyBanner.ts +84 -0
  146. package/src/dev/shortcuts.ts +158 -0
  147. package/src/errors/prettyPrintError.ts +5 -5
  148. package/src/errors/renderError.ts +230 -0
  149. package/src/hot.ts +12 -2
  150. package/src/index.ts +11 -1
  151. package/src/native/generated.ts +38 -0
  152. package/src/profiler/MemoryProfileStore.ts +75 -0
  153. package/src/profiler/Profiler.ts +182 -0
  154. package/src/profiler/collectors.ts +230 -0
  155. package/src/profiler/endpoint.ts +98 -0
  156. package/src/profiler/index.ts +56 -0
  157. package/src/profiler/middleware.ts +83 -0
  158. package/src/profiler/render.ts +173 -0
  159. package/src/profiler/toolbar.ts +106 -0
  160. package/src/profiler/types.ts +75 -0
  161. package/src/router/Router.ts +16 -0
  162. package/src/server/Server.ts +49 -0
  163. package/src/session/Session.ts +56 -2
  164. package/src/session/SessionMiddleware.ts +23 -4
  165. package/src/session/drivers/CookieDriver.ts +21 -6
  166. package/src/session/drivers/DatabaseDriver.ts +10 -0
  167. package/src/session/drivers/FileDriver.ts +91 -11
  168. package/src/session/drivers/MemoryDriver.ts +9 -0
  169. package/src/session/drivers/RedisDriver.ts +7 -0
  170. package/src/session/errors.ts +17 -0
@@ -5,23 +5,33 @@ import type { SessionDriver } from '../Session.js'
5
5
  * Cookie session driver — stores session data in an encrypted cookie (AES-256-GCM).
6
6
  * Stateless on the server. Limited by cookie size (~4KB).
7
7
  * Requires a secret for encryption.
8
+ *
9
+ * Sealed for the cookie's name, as upstream seals every encrypted cookie, so a
10
+ * value encrypted for another cookie under the same key cannot be presented as
11
+ * the session. And sealed with its expiry — the named deviation: upstream
12
+ * leaves expiry to the browser's `maxAge`, which a copied cookie does not
13
+ * obey, so a session lifted from a browser stayed valid for as long as the key
14
+ * did. Here a value older than the session's lifetime opens as nothing.
8
15
  */
9
16
  export class CookieDriver implements SessionDriver {
10
17
  #signer: CookieSigner
18
+ readonly #purpose: string
19
+ readonly stateless = true
11
20
 
12
- constructor(secret: string) {
21
+ constructor(secret: string, cookieName = 'ream_session') {
13
22
  if (!secret) {
14
23
  throw new Error(
15
24
  'CookieDriver requires a secret for session encryption. Set session.secret in your config.',
16
25
  )
17
26
  }
18
27
  this.#signer = new CookieSigner(secret)
28
+ this.#purpose = cookieName
19
29
  }
20
30
 
21
31
  async read(sessionId: string): Promise<Record<string, unknown> | null> {
22
32
  if (!sessionId) return null
23
- const decrypted = this.#signer.decrypt(sessionId)
24
- if (!decrypted) return null
33
+ const decrypted = this.#signer.decrypt(sessionId, this.#purpose)
34
+ if (typeof decrypted !== 'string') return null
25
35
  try {
26
36
  return JSON.parse(decrypted)
27
37
  } catch {
@@ -33,6 +43,11 @@ export class CookieDriver implements SessionDriver {
33
43
  // Cookie driver writes via the response cookie — handled by middleware
34
44
  }
35
45
 
46
+ async update(_sessionId: string, _data: Record<string, unknown>, _ttl: number): Promise<boolean> {
47
+ // Cookie driver writes via the response cookie — handled by middleware
48
+ return true
49
+ }
50
+
36
51
  async destroy(_sessionId: string): Promise<void> {
37
52
  // Cookie deletion handled by middleware setting maxAge=0
38
53
  }
@@ -41,8 +56,8 @@ export class CookieDriver implements SessionDriver {
41
56
  // Cookie renewal handled by middleware
42
57
  }
43
58
 
44
- /** Encode session data for cookie storage (encrypted). */
45
- encode(data: Record<string, unknown>): string {
46
- return this.#signer.encrypt(JSON.stringify(data))
59
+ /** Encode session data for cookie storage (encrypted), valid for `ttl` seconds. */
60
+ encode(data: Record<string, unknown>, ttl: number): string {
61
+ return this.#signer.encrypt(JSON.stringify(data), ttl * 1000, this.#purpose)
47
62
  }
48
63
  }
@@ -143,6 +143,16 @@ export class DatabaseDriver implements SessionDriverWithTagging {
143
143
  }
144
144
  }
145
145
 
146
+ /** One UPDATE: a row deleted or expired meanwhile matches nothing. */
147
+ async update(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<boolean> {
148
+ const now = Date.now()
149
+ const updated = await this.#db.execute(
150
+ `UPDATE ${this.#table} SET data = ?, expires_at = ? WHERE id = ? AND expires_at >= ?`,
151
+ [JSON.stringify(data), now + ttl * 1000, sessionId, now],
152
+ )
153
+ return updated.rowsAffected > 0
154
+ }
155
+
146
156
  async destroy(sessionId: string): Promise<void> {
147
157
  await this.#db.execute(`DELETE FROM ${this.#table} WHERE id = ?`, [sessionId])
148
158
  }
@@ -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