@bakery-framework/core 2.1.1 → 2.2.0
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/package.json +1 -1
- package/src/cache/tiered.ts +9 -0
- package/src/compiler/compiler.ts +10 -0
- package/src/core/config.ts +16 -0
- package/src/core/context.ts +8 -0
- package/src/core/index.ts +5 -0
- package/src/core/plugins.ts +24 -0
- package/src/global.d.ts +25 -14
- package/src/handlers/assets/nm.ts +5 -0
- package/src/handlers/assets/public.ts +4 -0
- package/src/handlers/assets/static.ts +5 -0
- package/src/handlers/assets/ts.ts +5 -0
- package/src/handlers/assets/virtual-asset.ts +5 -0
- package/src/handlers/core/$base.ts +28 -0
- package/src/logger/serve-log.ts +16 -0
- package/src/plugins/types.ts +8 -0
- package/src/router.ts +18 -5
- package/src/session.ts +516 -30
- package/src/startup.ts +18 -0
- package/src/utils/http/index.ts +1 -0
- package/src/utils/http/rate-limit.ts +56 -0
- package/src/utils/shared-pool.ts +120 -56
package/src/session.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { TieredCache } from './cache/tiered'
|
|
2
2
|
import { Bakery, hostKey } from './core/bakery'
|
|
3
|
+
import { peekConfig, resolveHostname } from './core/config'
|
|
4
|
+
import { hostStore } from './core/context'
|
|
5
|
+
import { errorMsg, serveLog } from './logger/serve-log'
|
|
3
6
|
import type { MapOf } from './types'
|
|
4
|
-
import { hasDeferredValue } from './utils'
|
|
7
|
+
import { deferredValue, hasDeferredValue } from './utils'
|
|
5
8
|
import { DEFAULT_SESSION_PERSIST, DEFAULT_SESSION_TTL } from './utils/constants'
|
|
6
9
|
|
|
7
10
|
/**
|
|
@@ -75,6 +78,141 @@ function inScope(key: string, scope: string): boolean {
|
|
|
75
78
|
return key.startsWith(scope) && !key.slice(scope.length).includes(':')
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
/** A session as a {@link SessionStore} keeps it. Times are epoch milliseconds. */
|
|
82
|
+
export interface StoredSession {
|
|
83
|
+
id: string
|
|
84
|
+
/** The configured host it belongs to, `''` for the default namespace. */
|
|
85
|
+
host: string
|
|
86
|
+
/**
|
|
87
|
+
* The account it belongs to, as text: the value under the key that
|
|
88
|
+
* `sessions.account` names, or null when there is none.
|
|
89
|
+
*/
|
|
90
|
+
account: string | null
|
|
91
|
+
createdAt: number
|
|
92
|
+
/** When it was last written, which is when its cookie was last issued. */
|
|
93
|
+
accessedAt: number
|
|
94
|
+
/** When a store may forget it: `accessedAt` plus its idle timeout. */
|
|
95
|
+
expiresAt: number
|
|
96
|
+
persistKeys: string[]
|
|
97
|
+
data: Record<string, unknown>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SessionListOptions {
|
|
101
|
+
search?: string
|
|
102
|
+
page: number
|
|
103
|
+
pageSize: number
|
|
104
|
+
sortBy: string
|
|
105
|
+
sortOrder: 'ASC' | 'DESC'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface SessionPage {
|
|
109
|
+
rows: Session<any>[]
|
|
110
|
+
totalRows: number
|
|
111
|
+
page: number
|
|
112
|
+
pageSize: number
|
|
113
|
+
totalPages: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Where sessions live when `sessions.store` replaces the built-in store,
|
|
118
|
+
* which is memory with `bakery/sessions.db` behind it.
|
|
119
|
+
*
|
|
120
|
+
* Every method is a round trip, and nothing here keeps a session between
|
|
121
|
+
* requests. That is the point: with the built-in store each `--threads`
|
|
122
|
+
* worker serves sessions from its own memory, so a session ended in one
|
|
123
|
+
* worker lived on in another. A store every worker asks, and nobody caches,
|
|
124
|
+
* ends it for the next request in all of them.
|
|
125
|
+
* `@bakery-framework/orm/sessions` implements it over the app's database.
|
|
126
|
+
*/
|
|
127
|
+
export interface SessionStore {
|
|
128
|
+
/** The session under this id and host, if it is still live at `now`. */
|
|
129
|
+
load(
|
|
130
|
+
host: string,
|
|
131
|
+
id: string,
|
|
132
|
+
now: number,
|
|
133
|
+
): Promise<StoredSession | undefined>
|
|
134
|
+
/** Store a session that has never been stored. */
|
|
135
|
+
insert(session: StoredSession): Promise<void>
|
|
136
|
+
/**
|
|
137
|
+
* Rewrite the session stored as `storedId`, which is its own id or the one
|
|
138
|
+
* it had before `regenerate()`. False when nothing is stored under that id
|
|
139
|
+
* any more, because something ended it after this request read it; then
|
|
140
|
+
* nothing is written, since an update must never bring a session back.
|
|
141
|
+
*/
|
|
142
|
+
update(storedId: string, session: StoredSession): Promise<boolean>
|
|
143
|
+
/** Renew a stored session's times without rewriting it. False as for `update`. */
|
|
144
|
+
touch(
|
|
145
|
+
host: string,
|
|
146
|
+
id: string,
|
|
147
|
+
accessedAt: number,
|
|
148
|
+
expiresAt: number,
|
|
149
|
+
): Promise<boolean>
|
|
150
|
+
/** True when a session was removed. */
|
|
151
|
+
remove(host: string, id: string): Promise<boolean>
|
|
152
|
+
/** Remove every session of one account under one host: how many went. */
|
|
153
|
+
removeAccount(host: string, account: string): Promise<number>
|
|
154
|
+
/** Live sessions across every host. */
|
|
155
|
+
count(now: number): Promise<number>
|
|
156
|
+
/**
|
|
157
|
+
* One page of the host's live sessions, and how many it has in all. A page
|
|
158
|
+
* past the end is the last page.
|
|
159
|
+
*/
|
|
160
|
+
list(
|
|
161
|
+
host: string,
|
|
162
|
+
options: SessionListOptions,
|
|
163
|
+
now: number,
|
|
164
|
+
): Promise<{ rows: StoredSession[]; totalRows: number }>
|
|
165
|
+
/** Forget what has expired by `now`: how many went. */
|
|
166
|
+
prune(now: number): Promise<number>
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** `sessions` in `server.config.ts`. */
|
|
170
|
+
export interface SessionOptions {
|
|
171
|
+
/** Where sessions live. Omitted, the built-in store. */
|
|
172
|
+
store?: SessionStore
|
|
173
|
+
/**
|
|
174
|
+
* The session key that holds the account id. A store indexes it, so
|
|
175
|
+
* `Session.endForAccount` can end every session of one account.
|
|
176
|
+
*/
|
|
177
|
+
account?: string
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The configured `sessions`, or undefined before `initConfig()`. Sessions are
|
|
182
|
+
* built outside a server too (a unit test, a script), and there the defaults
|
|
183
|
+
* are the answer rather than an error.
|
|
184
|
+
*/
|
|
185
|
+
function sessionOptions(): SessionOptions | undefined {
|
|
186
|
+
return (hostStore.getStore()?.config ?? peekConfig())?.sessions
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The configured store, or undefined for the built-in one. */
|
|
190
|
+
function sessionStore(): SessionStore | undefined {
|
|
191
|
+
return sessionOptions()?.store
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The host `hostKey()` would prefix, without the separator: a store's scope. */
|
|
195
|
+
function sessionHost(): string {
|
|
196
|
+
return resolveHostname(hostStore.getStore()?.hostname || '')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Ids worth a lookup: `newSessionId` mints 43 of these characters. A cookie
|
|
201
|
+
* holding anything else names no session a store could have, and costs no
|
|
202
|
+
* round trip.
|
|
203
|
+
*/
|
|
204
|
+
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/
|
|
205
|
+
|
|
206
|
+
/** The session `attach` read for a request, where `from` finds it. */
|
|
207
|
+
const PRELOADED = Symbol('bakery.session.preloaded')
|
|
208
|
+
|
|
209
|
+
/** A synchronous static that reads the built-in store, called with another configured. */
|
|
210
|
+
function builtInOnly(what: string, instead: string): never {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`${what} reads the built-in session store, and sessions.store replaces it: use ${instead}.`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
78
216
|
export class Session<
|
|
79
217
|
T extends MapOf<any> = MapOf<any>,
|
|
80
218
|
TK extends keyof T | (string & {}) = keyof T | (string & {}),
|
|
@@ -99,6 +237,7 @@ export class Session<
|
|
|
99
237
|
* would mean walking every key on every sample.
|
|
100
238
|
*/
|
|
101
239
|
public static get count() {
|
|
240
|
+
if (sessionStore()) builtInOnly('Session.count', 'await Session.total()')
|
|
102
241
|
return Session.cache.count
|
|
103
242
|
}
|
|
104
243
|
|
|
@@ -116,6 +255,12 @@ export class Session<
|
|
|
116
255
|
* request it belongs to issued a cookie this turn.
|
|
117
256
|
*/
|
|
118
257
|
public static bind(req: Request, response?: Response) {
|
|
258
|
+
if (sessionStore()) {
|
|
259
|
+
builtInOnly(
|
|
260
|
+
'Session.bind',
|
|
261
|
+
'await Session.commit(req) and append the cookie it returns',
|
|
262
|
+
)
|
|
263
|
+
}
|
|
119
264
|
if (!response) return response
|
|
120
265
|
|
|
121
266
|
const cookieValue = Session.getCookie(req)
|
|
@@ -125,9 +270,16 @@ export class Session<
|
|
|
125
270
|
}
|
|
126
271
|
|
|
127
272
|
public static getCookie(req: Request): string {
|
|
273
|
+
if (sessionStore()) {
|
|
274
|
+
builtInOnly('Session.getCookie', 'await Session.commit(req)')
|
|
275
|
+
}
|
|
128
276
|
if (!hasDeferredValue(req, 'session')) return ''
|
|
129
277
|
const session = req.session
|
|
130
278
|
|
|
279
|
+
// Ended from outside this request (`Session.delete`, `endForAccount`):
|
|
280
|
+
// storing it again would undo that.
|
|
281
|
+
if (session.ended) return ''
|
|
282
|
+
|
|
131
283
|
// Issue only when the session actually changed, or when the read path
|
|
132
284
|
// flagged a half-life refresh (see `markAccessed`), not on every read.
|
|
133
285
|
// A per-request Set-Cookie made every session-carrying response unique,
|
|
@@ -144,12 +296,58 @@ export class Session<
|
|
|
144
296
|
session.cookieRefreshDue = false
|
|
145
297
|
session.cookieIssuedAt = clock()
|
|
146
298
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
299
|
+
return Session.cookieHeader(session, req)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Write what this request did to its session, and return the `Set-Cookie`
|
|
304
|
+
* value for the response, `''` for none. `processResponse` calls it for
|
|
305
|
+
* every response but a WebSocket upgrade, whose headers are not its to set.
|
|
306
|
+
*
|
|
307
|
+
* With the built-in store this is `getCookie`. With `sessions.store` the
|
|
308
|
+
* writes happen here, awaited, so the response leaves after the store has
|
|
309
|
+
* them: a login answered with a redirect finds its session on the next
|
|
310
|
+
* request, whichever worker takes it.
|
|
311
|
+
*/
|
|
312
|
+
public static async commit(req: Request): Promise<string> {
|
|
313
|
+
const store = sessionStore()
|
|
314
|
+
if (!store) return Session.getCookie(req)
|
|
315
|
+
if (!hasDeferredValue(req, 'session')) return ''
|
|
316
|
+
return await req.session.commitTo(store, req)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Give the request its `req.session`. `worker.ts` calls it first, so the
|
|
321
|
+
* rate limiter's `keyBy` and every handler after it read the session
|
|
322
|
+
* synchronously.
|
|
323
|
+
*
|
|
324
|
+
* With the built-in store nothing is read until something asks: the
|
|
325
|
+
* property is deferred, and a request that never touches it costs nothing.
|
|
326
|
+
* A configured store cannot be asked synchronously, so a request carrying a
|
|
327
|
+
* session cookie waits here for one lookup by primary key, and one without
|
|
328
|
+
* a cookie does not wait at all.
|
|
329
|
+
*/
|
|
330
|
+
public static attach(req: Request): Promise<void> | undefined {
|
|
331
|
+
deferredValue(req, 'session', Session.from)
|
|
332
|
+
const store = sessionStore()
|
|
333
|
+
if (!store) return undefined
|
|
334
|
+
|
|
335
|
+
const id = Session.getSessionId(req)
|
|
336
|
+
if (!SESSION_ID.test(id)) return undefined
|
|
337
|
+
|
|
338
|
+
return store.load(sessionHost(), id, clock()).then(stored => {
|
|
339
|
+
// `load` asks for live sessions only. Checked again so a store that
|
|
340
|
+
// forgets to cannot hand back an expired one.
|
|
341
|
+
if (!stored || stored.expiresAt <= clock()) return
|
|
342
|
+
const session = Session.revive(stored)
|
|
343
|
+
session.markAccessed()
|
|
344
|
+
;(req as any)[PRELOADED] = session
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The `Set-Cookie` value naming `session`, for the response to `req`. */
|
|
349
|
+
private static cookieHeader(session: Session<any>, req: Request): string {
|
|
350
|
+
const maxAgeSeconds = Math.floor(session.idleTimeout() / 1000)
|
|
153
351
|
|
|
154
352
|
// Only believe x-forwarded-proto behind a trusted proxy: the same rule
|
|
155
353
|
// getHostname and getClientIp already apply. In production default to
|
|
@@ -165,8 +363,16 @@ export class Session<
|
|
|
165
363
|
}
|
|
166
364
|
|
|
167
365
|
public static delete(reqOrId: string | Request): boolean {
|
|
366
|
+
if (sessionStore()) builtInOnly('Session.delete', 'await Session.end(id)')
|
|
168
367
|
const id = this.getSessionId(reqOrId)
|
|
169
|
-
|
|
368
|
+
if (!id) return false
|
|
369
|
+
const key = sessionKey(id)
|
|
370
|
+
// A request holding this session right now must not store it again on
|
|
371
|
+
// its way out. The memory tier hands every request the same object, so
|
|
372
|
+
// marking it reaches them all.
|
|
373
|
+
const live = Session.cache.peek(key)
|
|
374
|
+
if (live) live.ended = true
|
|
375
|
+
return Session.cache.delete(key)
|
|
170
376
|
}
|
|
171
377
|
|
|
172
378
|
public static create<T extends MapOf<any>>(metadata: {
|
|
@@ -174,6 +380,9 @@ export class Session<
|
|
|
174
380
|
persistKeys: string[] | Set<string>
|
|
175
381
|
data: Partial<T>
|
|
176
382
|
}): Session<T> {
|
|
383
|
+
if (sessionStore()) {
|
|
384
|
+
builtInOnly('Session.create', 'req.session, which the response stores')
|
|
385
|
+
}
|
|
177
386
|
const session = Session.reconstruct<T>(metadata)
|
|
178
387
|
Session.cache.set(sessionKey(session.id), session)
|
|
179
388
|
return session
|
|
@@ -212,6 +421,8 @@ export class Session<
|
|
|
212
421
|
): Session<T> {
|
|
213
422
|
const rawReq = request as any
|
|
214
423
|
if (rawReq._session) return rawReq._session as any
|
|
424
|
+
// A configured store was read by `attach`, before anything could ask.
|
|
425
|
+
if (sessionStore()) return rawReq[PRELOADED] ?? new Session()
|
|
215
426
|
const sessionId = Session.getSessionId(request)
|
|
216
427
|
|
|
217
428
|
if (sessionId) {
|
|
@@ -260,6 +471,27 @@ export class Session<
|
|
|
260
471
|
protected rawData: Partial<T> = {}
|
|
261
472
|
public data!: Partial<T>
|
|
262
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Ended from outside the request holding it, by `Session.delete`,
|
|
476
|
+
* `Session.end` or `Session.endForAccount`: nothing it writes is stored
|
|
477
|
+
* again. `destroy()` is different: the request ending its own session may
|
|
478
|
+
* go on to start a new one.
|
|
479
|
+
*/
|
|
480
|
+
private ended = false
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* With `sessions.store`: the id the store holds this session under, or null
|
|
484
|
+
* while it holds nothing. It differs from `id` after `regenerate()`, until
|
|
485
|
+
* the commit moves the row.
|
|
486
|
+
*/
|
|
487
|
+
private storedId: string | null = null
|
|
488
|
+
|
|
489
|
+
/** With `sessions.store`: stored ids to remove at commit, in order. */
|
|
490
|
+
private removals: string[] = []
|
|
491
|
+
|
|
492
|
+
/** With `sessions.store`: when the store last wrote it. */
|
|
493
|
+
private storedAccessedAt: number | undefined
|
|
494
|
+
|
|
263
495
|
constructor(sessid?: string, createdAt?: number) {
|
|
264
496
|
sessid = sessid || newSessionId()
|
|
265
497
|
|
|
@@ -313,19 +545,33 @@ export class Session<
|
|
|
313
545
|
* of maxAge, so active sessions never age out server-side either.
|
|
314
546
|
*/
|
|
315
547
|
private markAccessed(): void {
|
|
316
|
-
|
|
317
|
-
? DEFAULT_SESSION_PERSIST
|
|
318
|
-
: DEFAULT_SESSION_TTL
|
|
319
|
-
if (clock() - this.cookieIssuedAt > maxAgeMs / 2) {
|
|
548
|
+
if (clock() - this.cookieIssuedAt > this.idleTimeout() / 2) {
|
|
320
549
|
this.cookieRefreshDue = true
|
|
321
550
|
}
|
|
322
551
|
}
|
|
323
552
|
|
|
553
|
+
/**
|
|
554
|
+
* How long the session lives unused, which is also its cookie's Max-Age:
|
|
555
|
+
* 30 days once a key is persisted, an hour otherwise.
|
|
556
|
+
*
|
|
557
|
+
* `hasPersistedKeys()` rather than `persistedKeys.length`: the getter
|
|
558
|
+
* allocates an Array off the Set purely to read its length.
|
|
559
|
+
*/
|
|
560
|
+
private idleTimeout(): number {
|
|
561
|
+
return this.hasPersistedKeys()
|
|
562
|
+
? DEFAULT_SESSION_PERSIST
|
|
563
|
+
: DEFAULT_SESSION_TTL
|
|
564
|
+
}
|
|
565
|
+
|
|
324
566
|
public get isModified() {
|
|
325
567
|
return this.modified
|
|
326
568
|
}
|
|
327
569
|
public get accessedAt() {
|
|
328
|
-
return
|
|
570
|
+
return (
|
|
571
|
+
this.storedAccessedAt ??
|
|
572
|
+
Session.cache.getAccessedAt(sessionKey(this.id)) ??
|
|
573
|
+
Date.now()
|
|
574
|
+
)
|
|
329
575
|
}
|
|
330
576
|
public get persistedKeys() {
|
|
331
577
|
return Array.from(this.persistKeys)
|
|
@@ -340,17 +586,15 @@ export class Session<
|
|
|
340
586
|
}
|
|
341
587
|
|
|
342
588
|
public isExpired(): boolean {
|
|
343
|
-
|
|
344
|
-
return this.hasPersistedKeys()
|
|
345
|
-
? Date.now() - accessed > DEFAULT_SESSION_PERSIST
|
|
346
|
-
: Date.now() - accessed > DEFAULT_SESSION_TTL
|
|
589
|
+
return Date.now() - this.accessedAt > this.idleTimeout()
|
|
347
590
|
}
|
|
348
591
|
|
|
349
592
|
public persist(key: keyof T | (string & {}), state: boolean = true): this {
|
|
350
593
|
this.persistKeys[state ? 'add' : 'delete'](key as string)
|
|
351
594
|
this.modified = true
|
|
352
595
|
|
|
353
|
-
|
|
596
|
+
// A configured store writes at commit, like every other change.
|
|
597
|
+
if (this.persistKeys.size > 0 && !sessionStore()) {
|
|
354
598
|
Session.cache.set(sessionKey(this.id), this)
|
|
355
599
|
}
|
|
356
600
|
|
|
@@ -383,8 +627,13 @@ export class Session<
|
|
|
383
627
|
// `id` is readonly to callers; rotating it is the one legitimate write.
|
|
384
628
|
;(this as { id: string }).id = newSessionId()
|
|
385
629
|
|
|
386
|
-
|
|
387
|
-
|
|
630
|
+
// A configured store moves the row at commit, from `storedId` to the new
|
|
631
|
+
// id in one statement, so a session ended meanwhile is not recreated
|
|
632
|
+
// under its new name.
|
|
633
|
+
if (!sessionStore()) {
|
|
634
|
+
Session.cache.delete(sessionKey(previous))
|
|
635
|
+
Session.cache.set(sessionKey(this.id), this)
|
|
636
|
+
}
|
|
388
637
|
this.modified = true
|
|
389
638
|
|
|
390
639
|
return this
|
|
@@ -398,17 +647,30 @@ export class Session<
|
|
|
398
647
|
delete this.rawData[key as keyof T]
|
|
399
648
|
}
|
|
400
649
|
|
|
650
|
+
const store = sessionStore()
|
|
401
651
|
if (!this.hasPersistedKeys()) {
|
|
402
|
-
|
|
652
|
+
if (store) this.forgetStored()
|
|
653
|
+
else Session.cache.delete(sessionKey(this.id))
|
|
403
654
|
this.modified = false
|
|
404
655
|
// A pending half-life refresh would make `getCookie` re-store the
|
|
405
656
|
// session this branch just deleted.
|
|
406
657
|
this.cookieRefreshDue = false
|
|
658
|
+
} else if (store) {
|
|
659
|
+
this.modified = true
|
|
407
660
|
} else {
|
|
408
661
|
Session.cache.set(sessionKey(this.id), this)
|
|
409
662
|
}
|
|
410
663
|
}
|
|
411
664
|
|
|
665
|
+
/**
|
|
666
|
+
* With `sessions.store`: remove the stored row at commit, and store
|
|
667
|
+
* anything written after this as a session of its own.
|
|
668
|
+
*/
|
|
669
|
+
private forgetStored(): void {
|
|
670
|
+
if (this.storedId !== null) this.removals.push(this.storedId)
|
|
671
|
+
this.storedId = null
|
|
672
|
+
}
|
|
673
|
+
|
|
412
674
|
get<K extends keyof T>(key: K): T[K] | undefined
|
|
413
675
|
get<K extends keyof T>(key: K, defaultValue: T[K]): T[K]
|
|
414
676
|
get<R = string>(key: string & {}): R | undefined
|
|
@@ -436,8 +698,110 @@ export class Session<
|
|
|
436
698
|
return this
|
|
437
699
|
}
|
|
438
700
|
|
|
701
|
+
/**
|
|
702
|
+
* End this session, and leave in its place a new one that nothing has
|
|
703
|
+
* written to.
|
|
704
|
+
*
|
|
705
|
+
* Dropping the stored entry was never enough on its own, because the object
|
|
706
|
+
* outlives the call: the same `req.session` reaches `getCookie` on the way
|
|
707
|
+
* out, and `getCookie` stores whatever is modified or due a cookie refresh.
|
|
708
|
+
* A login past half its cookie's Max-Age is due one, and a flash message
|
|
709
|
+
* written after the logout marks it modified, so either brought the whole
|
|
710
|
+
* session back, account id and all, under the id the visitor still held.
|
|
711
|
+
*
|
|
712
|
+
* So the object becomes a new session: a fresh id, no data, nothing
|
|
713
|
+
* persisted, nothing due. A request that stops here stores nothing and
|
|
714
|
+
* issues no cookie; one that goes on writing starts an anonymous session of
|
|
715
|
+
* its own, which the response issues as usual.
|
|
716
|
+
*/
|
|
439
717
|
public destroy(): void {
|
|
440
|
-
|
|
718
|
+
if (sessionStore()) this.forgetStored()
|
|
719
|
+
else Session.cache.delete(sessionKey(this.id))
|
|
720
|
+
;(this as { id: string }).id = newSessionId()
|
|
721
|
+
;(this as { createdAt: number }).createdAt = Date.now()
|
|
722
|
+
this.persistKeys.clear()
|
|
723
|
+
for (const key of Object.keys(this.rawData)) {
|
|
724
|
+
delete this.rawData[key as keyof T]
|
|
725
|
+
}
|
|
726
|
+
this.modified = false
|
|
727
|
+
this.cookieRefreshDue = false
|
|
728
|
+
this.cookieIssuedAt = 0
|
|
729
|
+
this.ended = false
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* With `sessions.store`, the writes `commit` makes: the removals first and
|
|
734
|
+
* in order, then the session itself.
|
|
735
|
+
*
|
|
736
|
+
* One statement at a time, never in parallel. A pool can run two
|
|
737
|
+
* statements on two connections, and a removal overtaken by the insert
|
|
738
|
+
* queued after it (`reset()` then a write keeps the id) would remove the
|
|
739
|
+
* session just written.
|
|
740
|
+
*/
|
|
741
|
+
private async commitTo(store: SessionStore, req: Request): Promise<string> {
|
|
742
|
+
const host = sessionHost()
|
|
743
|
+
for (const id of this.removals.splice(0)) await store.remove(host, id)
|
|
744
|
+
if (this.ended || (!this.modified && !this.cookieRefreshDue)) return ''
|
|
745
|
+
|
|
746
|
+
const now = clock()
|
|
747
|
+
const stored = this.toStored(host, now)
|
|
748
|
+
let kept = true
|
|
749
|
+
if (this.storedId === null) await store.insert(stored)
|
|
750
|
+
else if (this.modified) kept = await store.update(this.storedId, stored)
|
|
751
|
+
else kept = await store.touch(host, this.storedId, now, stored.expiresAt)
|
|
752
|
+
|
|
753
|
+
if (!kept) {
|
|
754
|
+
// Ended after this request read it: by `endForAccount`, by a logout in
|
|
755
|
+
// another tab, in this worker or another. Writing it back would undo
|
|
756
|
+
// that, and a new cookie would name a session that is not there.
|
|
757
|
+
this.ended = true
|
|
758
|
+
return ''
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
this.storedId = this.id
|
|
762
|
+
this.storedAccessedAt = now
|
|
763
|
+
this.cookieIssuedAt = now
|
|
764
|
+
this.modified = false
|
|
765
|
+
this.cookieRefreshDue = false
|
|
766
|
+
return Session.cookieHeader(this, req)
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* As a store keeps it, last written at `accessedAt`. The account is read
|
|
771
|
+
* off the key `sessions.account` names, as text, so `7` and `'7'` are one
|
|
772
|
+
* account.
|
|
773
|
+
*/
|
|
774
|
+
private toStored(host: string, accessedAt: number): StoredSession {
|
|
775
|
+
const key = sessionOptions()?.account
|
|
776
|
+
const held = key ? (this.rawData as MapOf<unknown>)[key] : undefined
|
|
777
|
+
return {
|
|
778
|
+
id: this.id,
|
|
779
|
+
host,
|
|
780
|
+
account:
|
|
781
|
+
held === undefined || held === null || held === ''
|
|
782
|
+
? null
|
|
783
|
+
: String(held),
|
|
784
|
+
createdAt: this.createdAt,
|
|
785
|
+
accessedAt,
|
|
786
|
+
expiresAt: accessedAt + this.idleTimeout(),
|
|
787
|
+
persistKeys: Array.from(this.persistKeys),
|
|
788
|
+
data: { ...this.rawData },
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** A session read from a store, remembering where it is stored. */
|
|
793
|
+
private static revive(stored: StoredSession): Session<any> {
|
|
794
|
+
const session = Session.reconstruct({
|
|
795
|
+
id: stored.id,
|
|
796
|
+
createdAt: stored.createdAt,
|
|
797
|
+
persistKeys: stored.persistKeys,
|
|
798
|
+
data: stored.data,
|
|
799
|
+
// A store writes a session exactly when its cookie is issued.
|
|
800
|
+
cookieIssuedAt: stored.accessedAt,
|
|
801
|
+
})
|
|
802
|
+
session.storedId = stored.id
|
|
803
|
+
session.storedAccessedAt = stored.accessedAt
|
|
804
|
+
return session
|
|
441
805
|
}
|
|
442
806
|
|
|
443
807
|
public toJSON() {
|
|
@@ -457,16 +821,134 @@ export class Session<
|
|
|
457
821
|
}
|
|
458
822
|
}
|
|
459
823
|
|
|
824
|
+
/**
|
|
825
|
+
* The live session under this id, on the current host. With a configured
|
|
826
|
+
* store this is a copy read for the caller: change it, then
|
|
827
|
+
* `await Session.save(session)`.
|
|
828
|
+
*/
|
|
460
829
|
static async get(sessid: string): Promise<Session<any> | undefined> {
|
|
461
|
-
|
|
830
|
+
const store = sessionStore()
|
|
831
|
+
if (!store) return Session.cache.get(sessionKey(sessid))
|
|
832
|
+
const stored = await store.load(sessionHost(), sessid, clock())
|
|
833
|
+
return stored ? Session.revive(stored) : undefined
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* End the session under this id, on the current host: true when there was
|
|
838
|
+
* one. Unlike `Session.delete`, works with any store.
|
|
839
|
+
*
|
|
840
|
+
* A request holding that session right now finishes with what it read,
|
|
841
|
+
* and writes nothing back on its way out.
|
|
842
|
+
*/
|
|
843
|
+
static async end(sessid: string): Promise<boolean> {
|
|
844
|
+
const store = sessionStore()
|
|
845
|
+
if (!store) return Session.delete(sessid)
|
|
846
|
+
return await store.remove(sessionHost(), sessid)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* End every session of one account, on the current host: how many there
|
|
851
|
+
* were. The account is matched as text against the session key that
|
|
852
|
+
* `sessions.account` names, so `7` and `'7'` are one account.
|
|
853
|
+
*
|
|
854
|
+
* With a configured store this is one indexed `DELETE`, and since nothing
|
|
855
|
+
* caches a session between requests, the next request in every worker
|
|
856
|
+
* finds none. Inside `DB.transaction()` the database store takes part in
|
|
857
|
+
* the transaction, so removing an account and its sessions can be one
|
|
858
|
+
* commit. With the built-in store it ends what this process holds, which
|
|
859
|
+
* under `--threads` is not what another worker holds in its memory: that is
|
|
860
|
+
* the case the database store exists for.
|
|
861
|
+
*/
|
|
862
|
+
static async endForAccount(account: string | number): Promise<number> {
|
|
863
|
+
const key = sessionOptions()?.account
|
|
864
|
+
if (!key) {
|
|
865
|
+
throw new Error(
|
|
866
|
+
'Session.endForAccount needs sessions.account in server.config.ts: the session key that holds the account id.',
|
|
867
|
+
)
|
|
868
|
+
}
|
|
869
|
+
const wanted = String(account)
|
|
870
|
+
const store = sessionStore()
|
|
871
|
+
if (store) return await store.removeAccount(sessionHost(), wanted)
|
|
872
|
+
|
|
873
|
+
// Collected first and deleted after: deleting rows from the table a
|
|
874
|
+
// statement is still walking is not something SQLite promises to handle.
|
|
875
|
+
const ended: string[] = []
|
|
876
|
+
for (const [id, session] of Session.entries()) {
|
|
877
|
+
const held = (session.rawData as MapOf<unknown>)[key]
|
|
878
|
+
if (held !== undefined && held !== null && String(held) === wanted) {
|
|
879
|
+
ended.push(id)
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
for (const id of ended) Session.delete(id)
|
|
883
|
+
return ended.length
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** How many sessions are live, across every host. Works with any store. */
|
|
887
|
+
static async total(): Promise<number> {
|
|
888
|
+
const store = sessionStore()
|
|
889
|
+
return store ? await store.count(clock()) : Session.cache.count
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* One page of the current host's sessions, as `Session.list` gives it, from
|
|
894
|
+
* any store. Sorted by `'id'`, `'keys'` (how many are persisted) or last
|
|
895
|
+
* access, the default.
|
|
896
|
+
*/
|
|
897
|
+
static async page(options: SessionListOptions): Promise<SessionPage> {
|
|
898
|
+
const store = sessionStore()
|
|
899
|
+
if (!store) return Session.list(options)
|
|
900
|
+
const { rows, totalRows } = await store.list(
|
|
901
|
+
sessionHost(),
|
|
902
|
+
options,
|
|
903
|
+
clock(),
|
|
904
|
+
)
|
|
905
|
+
const totalPages = Math.max(1, Math.ceil(totalRows / options.pageSize))
|
|
906
|
+
return {
|
|
907
|
+
rows: rows.map(Session.revive),
|
|
908
|
+
totalRows,
|
|
909
|
+
page: Math.min(Math.max(1, options.page), totalPages),
|
|
910
|
+
pageSize: options.pageSize,
|
|
911
|
+
totalPages,
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Store a session changed outside its own requests, such as one an admin
|
|
917
|
+
* edits from `Session.get`. False when it has been ended meanwhile, and
|
|
918
|
+
* then it stays ended.
|
|
919
|
+
*
|
|
920
|
+
* Its last access is left alone. With a configured store that time is
|
|
921
|
+
* when the session's cookie was issued, and moving it on an edit would
|
|
922
|
+
* delay the owner's cookie refresh past the cookie's own expiry.
|
|
923
|
+
*/
|
|
924
|
+
static async save(session: Session<any>): Promise<boolean> {
|
|
925
|
+
const store = sessionStore()
|
|
926
|
+
if (!store) {
|
|
927
|
+
Session.cache.set(sessionKey(session.id), session)
|
|
928
|
+
return true
|
|
929
|
+
}
|
|
930
|
+
if (session.ended) return false
|
|
931
|
+
const stored = session.toStored(
|
|
932
|
+
sessionHost(),
|
|
933
|
+
session.storedAccessedAt ?? clock(),
|
|
934
|
+
)
|
|
935
|
+
if (session.storedId === null) await store.insert(stored)
|
|
936
|
+
else if (!(await store.update(session.storedId, stored))) return false
|
|
937
|
+
session.storedId = session.id
|
|
938
|
+
session.storedAccessedAt = stored.accessedAt
|
|
939
|
+
return true
|
|
462
940
|
}
|
|
463
941
|
|
|
464
942
|
/**
|
|
465
943
|
* Every enumeration below filters to the current host and yields the bare
|
|
466
944
|
* session id, not the cache key, so `Session.keys()` still returns ids the
|
|
467
945
|
* dashboard can hand back to `Session.get` / `Session.delete`.
|
|
946
|
+
*
|
|
947
|
+
* They read the built-in store, synchronously, and refuse when a configured
|
|
948
|
+
* store replaces it: `Session.page()` and `Session.total()` ask any store.
|
|
468
949
|
*/
|
|
469
950
|
static *entries(): IterableIterator<[string, Session<any>]> {
|
|
951
|
+
if (sessionStore()) builtInOnly('Session.entries', 'await Session.page()')
|
|
470
952
|
const scope = sessionScope()
|
|
471
953
|
for (const [key, sess] of Session.cache.entries()) {
|
|
472
954
|
if (!inScope(key, scope)) continue
|
|
@@ -482,13 +964,8 @@ export class Session<
|
|
|
482
964
|
for (const [id] of Session.entries()) yield id
|
|
483
965
|
}
|
|
484
966
|
|
|
485
|
-
static list(options: {
|
|
486
|
-
|
|
487
|
-
page: number
|
|
488
|
-
pageSize: number
|
|
489
|
-
sortBy: string
|
|
490
|
-
sortOrder: 'ASC' | 'DESC'
|
|
491
|
-
}) {
|
|
967
|
+
static list(options: SessionListOptions): SessionPage {
|
|
968
|
+
if (sessionStore()) builtInOnly('Session.list', 'await Session.page()')
|
|
492
969
|
// Fast path: with no `hosts` configured every key is already in the default
|
|
493
970
|
// bucket, so the cache's own SQL paging is correctly scoped and there is no
|
|
494
971
|
// reason to give it up.
|
|
@@ -551,6 +1028,15 @@ export class Session<
|
|
|
551
1028
|
|
|
552
1029
|
const sessionPruneTimer = setInterval(
|
|
553
1030
|
function cleanUpSessions() {
|
|
1031
|
+
// Every worker runs this timer. Against a shared store that is one
|
|
1032
|
+
// indexed DELETE each per quarter hour, and a second finds nothing.
|
|
1033
|
+
const store = sessionStore()
|
|
1034
|
+
if (store) {
|
|
1035
|
+
store
|
|
1036
|
+
.prune(clock())
|
|
1037
|
+
.catch(error => serveLog.SESSION_PRUNE_ERR({ error: errorMsg(error) }))
|
|
1038
|
+
return
|
|
1039
|
+
}
|
|
554
1040
|
Session.cache.prune(DEFAULT_SESSION_TTL, '$.persistKeys')
|
|
555
1041
|
Session.cache.prune(DEFAULT_SESSION_PERSIST)
|
|
556
1042
|
},
|