@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.
- package/README.md +1 -0
- package/dist/Ignitor.d.ts +3 -1
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +7 -0
- package/dist/Ignitor.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/native/generated.d.ts +34 -0
- package/dist/native/generated.d.ts.map +1 -1
- package/dist/server/Server.d.ts +37 -0
- package/dist/server/Server.d.ts.map +1 -1
- package/dist/server/Server.js +30 -0
- package/dist/server/Server.js.map +1 -1
- package/dist/session/Session.d.ts +33 -0
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js +31 -2
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +24 -4
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/drivers/CookieDriver.d.ts +12 -3
- package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
- package/dist/session/drivers/CookieDriver.js +20 -6
- package/dist/session/drivers/CookieDriver.js.map +1 -1
- package/dist/session/drivers/DatabaseDriver.d.ts +2 -0
- package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
- package/dist/session/drivers/DatabaseDriver.js +6 -0
- package/dist/session/drivers/DatabaseDriver.js.map +1 -1
- package/dist/session/drivers/FileDriver.d.ts +8 -0
- package/dist/session/drivers/FileDriver.d.ts.map +1 -1
- package/dist/session/drivers/FileDriver.js +85 -11
- package/dist/session/drivers/FileDriver.js.map +1 -1
- package/dist/session/drivers/MemoryDriver.d.ts +2 -0
- package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +9 -0
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/session/drivers/RedisDriver.d.ts +2 -0
- package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/session/drivers/RedisDriver.js +6 -0
- package/dist/session/drivers/RedisDriver.js.map +1 -1
- package/dist/session/errors.d.ts +10 -0
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +12 -0
- package/dist/session/errors.js.map +1 -1
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/scheduler.darwin-arm64.node +0 -0
- package/scheduler.darwin-x64.node +0 -0
- package/scheduler.linux-arm64-gnu.node +0 -0
- package/scheduler.linux-x64-gnu.node +0 -0
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Ignitor.ts +14 -1
- package/src/index.ts +5 -1
- package/src/native/generated.ts +38 -0
- package/src/server/Server.ts +49 -0
- package/src/session/Session.ts +56 -2
- package/src/session/SessionMiddleware.ts +23 -4
- package/src/session/drivers/CookieDriver.ts +21 -6
- package/src/session/drivers/DatabaseDriver.ts +10 -0
- package/src/session/drivers/FileDriver.ts +91 -11
- package/src/session/drivers/MemoryDriver.ts +9 -0
- package/src/session/drivers/RedisDriver.ts +7 -0
- 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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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))
|
package/src/session/errors.ts
CHANGED
|
@@ -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
|