@bakery-framework/core 1.0.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.
Files changed (91) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +89 -0
  3. package/package.json +69 -0
  4. package/src/cache/index.ts +8 -0
  5. package/src/cache/lru.ts +41 -0
  6. package/src/cache/shared-db.ts +51 -0
  7. package/src/cache/string.ts +150 -0
  8. package/src/cache/tiered.ts +493 -0
  9. package/src/client/globals.d.ts +74 -0
  10. package/src/client/livereload.ts +437 -0
  11. package/src/client/utils.ts +315 -0
  12. package/src/compiler/compiler.ts +263 -0
  13. package/src/compiler/dev-service.ts +660 -0
  14. package/src/compiler/index.ts +2 -0
  15. package/src/compiler/prompt-tracker.ts +36 -0
  16. package/src/compiler/tsconfig-sync.ts +71 -0
  17. package/src/core/bakery.ts +96 -0
  18. package/src/core/cache-version.ts +119 -0
  19. package/src/core/config.ts +296 -0
  20. package/src/core/context.ts +121 -0
  21. package/src/core/index.ts +61 -0
  22. package/src/core/init.ts +90 -0
  23. package/src/core/jsx.ts +152 -0
  24. package/src/core/paths.ts +24 -0
  25. package/src/core/plugins.ts +120 -0
  26. package/src/core/port.ts +73 -0
  27. package/src/global.d.ts +374 -0
  28. package/src/handlers/assets/google-font.ts +225 -0
  29. package/src/handlers/assets/image.ts +136 -0
  30. package/src/handlers/assets/nm.ts +73 -0
  31. package/src/handlers/assets/public.ts +17 -0
  32. package/src/handlers/assets/static.ts +86 -0
  33. package/src/handlers/assets/ts.ts +61 -0
  34. package/src/handlers/assets/tsx.ts +106 -0
  35. package/src/handlers/assets/virtual-asset.ts +104 -0
  36. package/src/handlers/core/$base.ts +256 -0
  37. package/src/handlers/core/$dynamic.ts +285 -0
  38. package/src/handlers/core/$error.ts +301 -0
  39. package/src/handlers/core/$middleware.ts +71 -0
  40. package/src/handlers/core/$mounts.ts +84 -0
  41. package/src/handlers/core/$registry.ts +153 -0
  42. package/src/handlers/core/$routing.ts +205 -0
  43. package/src/handlers/core/$static.ts +100 -0
  44. package/src/handlers/core/$websocket.ts +52 -0
  45. package/src/handlers/index.ts +21 -0
  46. package/src/handlers/routes/api.ts +95 -0
  47. package/src/handlers/routes/html.ts +95 -0
  48. package/src/handlers/routes/livereload.ts +54 -0
  49. package/src/handlers/routes/proxy.ts +74 -0
  50. package/src/logger/clients.ts +12 -0
  51. package/src/logger/index.ts +3 -0
  52. package/src/logger/logger.ts +375 -0
  53. package/src/logger/serve-log.ts +206 -0
  54. package/src/plugins/index.ts +15 -0
  55. package/src/plugins/routes.ts +110 -0
  56. package/src/plugins/types.ts +19 -0
  57. package/src/router.ts +351 -0
  58. package/src/session.ts +556 -0
  59. package/src/shared.d.ts +63 -0
  60. package/src/startup.ts +154 -0
  61. package/src/types.d.ts +111 -0
  62. package/src/utils/common/case.ts +11 -0
  63. package/src/utils/common/index.ts +5 -0
  64. package/src/utils/common/json.ts +35 -0
  65. package/src/utils/common/match.ts +6 -0
  66. package/src/utils/common/misc.ts +53 -0
  67. package/src/utils/common/try.ts +6 -0
  68. package/src/utils/constants.ts +153 -0
  69. package/src/utils/fs.ts +621 -0
  70. package/src/utils/http/body.ts +65 -0
  71. package/src/utils/http/csrf.ts +111 -0
  72. package/src/utils/http/dom.ts +238 -0
  73. package/src/utils/http/escape.ts +8 -0
  74. package/src/utils/http/etag.ts +318 -0
  75. package/src/utils/http/html.ts +525 -0
  76. package/src/utils/http/index.ts +8 -0
  77. package/src/utils/http/ip.ts +32 -0
  78. package/src/utils/http/response.ts +129 -0
  79. package/src/utils/index.ts +4 -0
  80. package/src/utils/isomorphic/case.ts +52 -0
  81. package/src/utils/isomorphic/escape.ts +43 -0
  82. package/src/utils/isomorphic/index.ts +15 -0
  83. package/src/utils/isomorphic/is.ts +36 -0
  84. package/src/utils/isomorphic/match.ts +50 -0
  85. package/src/utils/isomorphic/math.ts +11 -0
  86. package/src/utils/isomorphic/misc.ts +22 -0
  87. package/src/utils/isomorphic/stringify.ts +42 -0
  88. package/src/utils/isomorphic/try.ts +94 -0
  89. package/src/utils/jsonc.ts +10 -0
  90. package/src/utils/shared-pool.ts +193 -0
  91. package/tsconfig.app.json +34 -0
package/src/session.ts ADDED
@@ -0,0 +1,556 @@
1
+ import { TieredCache } from './cache/tiered'
2
+ import { Bakery, hostKey } from './core/bakery'
3
+ import type { MapOf } from './types'
4
+ import { hasDeferredValue } from './utils'
5
+ import { DEFAULT_SESSION_PERSIST, DEFAULT_SESSION_TTL } from './utils/constants'
6
+
7
+ /**
8
+ * Keys under this prefix are framework-internal privilege markers. They share
9
+ * the same bag as application data, so anything that writes a caller-supplied
10
+ * key must refuse this prefix — otherwise a preferences endpoint (or the
11
+ * dashboard's own session editor) becomes a privilege-escalation primitive.
12
+ */
13
+ export const RESERVED_SESSION_PREFIX = '__bakery.'
14
+
15
+ /** Marks a session as having passed the DASHPASS check. */
16
+ export const DASHPASS_SESSION_KEY = `${RESERVED_SESSION_PREFIX}dashpass`
17
+
18
+ export function isReservedSessionKey(key: string): boolean {
19
+ return key.startsWith(RESERVED_SESSION_PREFIX)
20
+ }
21
+
22
+ /**
23
+ * Clock seam — the cookie half-life bookkeeping is time-based, and its tests
24
+ * inject a clock instead of sleeping (see `__setTestDb` / `__setTestConfig`
25
+ * for the pattern). Only the cookie-reissue math reads this; `createdAt` and
26
+ * TTL expiry keep `Date.now()`.
27
+ */
28
+ let clock: () => number = Date.now
29
+
30
+ export function __setTestClock(fn: () => number): void {
31
+ clock = fn
32
+ }
33
+
34
+ export function __resetTestClock(): void {
35
+ clock = Date.now
36
+ }
37
+
38
+ /**
39
+ * The session id is the sole bearer token, so it must be unguessable. UUIDv7
40
+ * carries a monotonic millisecond timestamp and only ~74 random bits, which
41
+ * makes ids issued at a known time partially predictable.
42
+ */
43
+ export function newSessionId(): string {
44
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
45
+ 'base64url',
46
+ )
47
+ }
48
+
49
+ /**
50
+ * The cache key for a session id under the current host.
51
+ *
52
+ * There is one `TieredCache('sessions')` for the whole process, and until this
53
+ * existed every id went into it unqualified — so under a multi-tenant `hosts`
54
+ * config one tenant's dashboard listed, read and deleted every other tenant's
55
+ * sessions. `hostKey()` is the guard the five other per-tenant caches already
56
+ * use; sessions were the one that never got it.
57
+ *
58
+ * An unconfigured host resolves to the empty prefix (see `hostKey`), which is
59
+ * also the single-host case, so a single-host app's keys are unchanged.
60
+ */
61
+ function sessionKey(id: string): string {
62
+ return hostKey(id)
63
+ }
64
+
65
+ /** `'host:'` under a configured host, `''` otherwise. */
66
+ function sessionScope(): string {
67
+ return hostKey('')
68
+ }
69
+
70
+ /**
71
+ * Whether a raw cache key belongs to `scope`.
72
+ *
73
+ * `startsWith` alone is not enough for the default scope, where every key would
74
+ * match. Session ids are base64url and can never contain `:`, so what follows
75
+ * the prefix identifies the bucket unambiguously.
76
+ */
77
+ function inScope(key: string, scope: string): boolean {
78
+ return key.startsWith(scope) && !key.slice(scope.length).includes(':')
79
+ }
80
+
81
+ export class Session<
82
+ T extends MapOf<any> = MapOf<any>,
83
+ TK extends keyof T | (string & {}) = keyof T | (string & {}),
84
+ > {
85
+ public static cache = new TieredCache<string, Session<any>>('sessions', {
86
+ memoryThreshold: 1000,
87
+ flushInterval: 30000,
88
+ reviver: (json: any) =>
89
+ Session.reconstruct({
90
+ id: json.id,
91
+ createdAt: json.createdAt,
92
+ persistKeys: json.persistKeys,
93
+ data: json.data,
94
+ cookieIssuedAt: json.cookieIssuedAt,
95
+ }),
96
+ shouldPersist: session => session.hasPersistedKeys() || session.hasData(),
97
+ })
98
+
99
+ /**
100
+ * Process-wide session count, deliberately not host-scoped: it is a capacity
101
+ * metric (analytics samples it on a timer), not tenant data, and scoping it
102
+ * would mean walking every key on every sample.
103
+ */
104
+ public static get count() {
105
+ return Session.cache.count
106
+ }
107
+
108
+ public static bind(req: Request, response?: Response) {
109
+ if (!response) return response
110
+
111
+ const cookieValue = Session.getCookie(req)
112
+ if (cookieValue) response.headers.append('Set-Cookie', cookieValue)
113
+
114
+ return response
115
+ }
116
+
117
+ public static getCookie(req: Request): string {
118
+ if (!hasDeferredValue(req, 'session')) return ''
119
+ const session = req.session
120
+
121
+ // Issue only when the session actually changed, or when the read path
122
+ // flagged a half-life refresh (see `markAccessed`) — not on every read.
123
+ // A per-request Set-Cookie made every session-carrying response unique,
124
+ // defeating If-None-Match, and dirtied merely-read sessions into the
125
+ // flush timer's write batch.
126
+ if (!session.modified && !session.cookieRefreshDue) return ''
127
+
128
+ // Storing here serves both cases: a modified session must persist, and a
129
+ // half-life refresh must renew the row's accessedAt so the pruner's
130
+ // server-side TTL slides with the cookie (and `cookieIssuedAt` rides
131
+ // along in the same write).
132
+ Session.cache.set(sessionKey(session.id), session)
133
+ session.modified = false
134
+ session.cookieRefreshDue = false
135
+ session.cookieIssuedAt = clock()
136
+
137
+ // `persistedKeys` allocates an Array off the Set purely to read `.length`;
138
+ // `hasPersistedKeys()` reads `Set.size` and answers the same question.
139
+ const hasPersistedKeys = session.hasPersistedKeys()
140
+ const maxAgeSeconds = hasPersistedKeys
141
+ ? Math.floor(DEFAULT_SESSION_PERSIST / 1000)
142
+ : Math.floor(DEFAULT_SESSION_TTL / 1000)
143
+
144
+ // Only believe x-forwarded-proto behind a trusted proxy — the same rule
145
+ // getHostname and getClientIp already apply. In production default to
146
+ // Secure, so a terminator that omits the header can't downgrade the cookie.
147
+ const trustProxy = Bakery.config.trustProxy
148
+ const forwardedHttps =
149
+ trustProxy && req.headers.get('x-forwarded-proto') === 'https'
150
+ const isHttps = Boolean(
151
+ req.url?.startsWith('https:') || forwardedHttps || import.meta.env.PROD,
152
+ )
153
+ const secureFlag = isHttps ? '; Secure' : ''
154
+ return `sId=${session.id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secureFlag}`
155
+ }
156
+
157
+ public static delete(reqOrId: string | Request): boolean {
158
+ const id = this.getSessionId(reqOrId)
159
+ return id ? Session.cache.delete(sessionKey(id)) : false
160
+ }
161
+
162
+ public static create<T extends MapOf<any>>(metadata: {
163
+ id: string
164
+ persistKeys: string[] | Set<string>
165
+ data: Partial<T>
166
+ }): Session<T> {
167
+ const session = Session.reconstruct<T>(metadata)
168
+ Session.cache.set(sessionKey(session.id), session)
169
+ return session
170
+ }
171
+
172
+ public static reconstruct<T extends MapOf<any>>(metadata: {
173
+ id: string
174
+ createdAt?: number
175
+ persistKeys: string[] | Set<string>
176
+ data: Partial<T>
177
+ cookieIssuedAt?: number
178
+ }): Session<T> {
179
+ const session = new Session<T>(metadata.id, metadata.createdAt)
180
+ session.persistKeys = new Set(metadata.persistKeys)
181
+ session.rawData = { ...metadata.data }
182
+ session.data = session.initProxy()
183
+ // Rows written before the field existed revive as 0 = "unknown", which
184
+ // `markAccessed` reads as long past half-life — at worst one extra
185
+ // reissue, never a cookie that silently expires under an active user.
186
+ session.cookieIssuedAt = metadata.cookieIssuedAt ?? 0
187
+ return session
188
+ }
189
+
190
+ private static getSessionId(request: Request | string): string {
191
+ if (typeof request === 'string') return request || newSessionId()
192
+ const rawReq = request as any
193
+ if (rawReq._session) return rawReq._session.id
194
+
195
+ if (!request.headers.has('cookie')) return ''
196
+ const cookieHeader = request.headers.get('cookie') || ''
197
+ return cookieHeader.match(/(?:^|;\s*)sId=([^;]+)/)?.[1] || ''
198
+ }
199
+
200
+ public static from<T extends MapOf<any> = MapOf<any>>(
201
+ request: Request,
202
+ ): Session<T> {
203
+ const rawReq = request as any
204
+ if (rawReq._session) return rawReq._session as any
205
+ const sessionId = Session.getSessionId(request)
206
+
207
+ if (sessionId) {
208
+ const existing = Session.cache.get(sessionKey(sessionId))
209
+ if (existing) {
210
+ // Enforce the TTL on read. Previously expiry relied solely on the
211
+ // 15-minute prune interval, so a stolen id stayed usable past its TTL.
212
+ if (existing.isExpired()) {
213
+ Session.cache.delete(sessionKey(sessionId))
214
+ } else {
215
+ // Accessed, not modified — see `markAccessed`. `touch()` here made
216
+ // every session read count as a write.
217
+ existing.markAccessed()
218
+ return existing
219
+ }
220
+ }
221
+ }
222
+
223
+ return new Session()
224
+ }
225
+
226
+ public readonly id!: string
227
+ public readonly createdAt!: number
228
+
229
+ protected modified: boolean = false
230
+
231
+ /**
232
+ * Epoch ms when `getCookie` last emitted `Set-Cookie` for this id; 0 means
233
+ * never (or unknown — a row written before the field existed). Persisted
234
+ * through `toJSON`, so it only costs a write when a write is already
235
+ * happening; while the instance lives in the memory tier it carries across
236
+ * requests for free.
237
+ */
238
+ protected cookieIssuedAt: number = 0
239
+
240
+ /**
241
+ * Set by `markAccessed` when the cookie has crossed half its Max-Age;
242
+ * cleared when `getCookie` issues. Deliberately not persisted — it is
243
+ * per-instance request bookkeeping, and losing it costs nothing (the next
244
+ * read recomputes it from `cookieIssuedAt`).
245
+ */
246
+ protected cookieRefreshDue: boolean = false
247
+
248
+ protected persistKeys!: Set<string>
249
+
250
+ protected rawData: Partial<T> = {}
251
+ public data!: Partial<T>
252
+
253
+ constructor(sessid?: string, createdAt?: number) {
254
+ sessid = sessid || newSessionId()
255
+
256
+ this.id = sessid
257
+ this.createdAt = createdAt || Date.now()
258
+ this.persistKeys = new Set()
259
+ this.rawData = {}
260
+ this.data = this.initProxy()
261
+ }
262
+
263
+ private initProxy(): Partial<T> {
264
+ return new Proxy(this.rawData, {
265
+ get: (target, prop: string) => target[prop as keyof T],
266
+ set: (target, prop: string, value) => {
267
+ target[prop as keyof T] = value
268
+ this.modified = true
269
+ return true
270
+ },
271
+ deleteProperty: (target, prop: string) => {
272
+ delete target[prop as keyof T]
273
+ this.modified = true
274
+ return true
275
+ },
276
+ })
277
+ }
278
+
279
+ /**
280
+ * Force the session to count as modified: stored on the next flush and its
281
+ * cookie re-issued. This is the published "force a cookie refresh" surface
282
+ * (docs/guides/sessions.md) and keeps its dirty semantics. It is *not* the
283
+ * read path — `Session.from` uses `markAccessed`, which slides expiry
284
+ * without paying write costs.
285
+ */
286
+ public touch(): this {
287
+ this.modified = true
288
+ return this
289
+ }
290
+
291
+ /**
292
+ * The read-path bump — what `Session.from` does instead of `touch()`.
293
+ * Reading a session must not dirty it: that made every read-only request
294
+ * pay a JSON.stringify + synchronous SQLite write on the next flush and
295
+ * re-issue Set-Cookie, which defeated If-None-Match caching.
296
+ *
297
+ * Liveness needs no work here: the `Session.cache.get` in `from` has
298
+ * already re-stamped the memory tier's accessedAt. What this method owns is
299
+ * sliding cookie expiration — once more than half the cookie's Max-Age has
300
+ * elapsed since it was last issued, flag a reissue. `getCookie` then emits
301
+ * the cookie *and* re-persists the session, which renews the DB row's
302
+ * accessedAt at a cadence of at most maxAge/2 against the pruner's cutoff
303
+ * of maxAge — so active sessions never age out server-side either.
304
+ */
305
+ private markAccessed(): void {
306
+ const maxAgeMs = this.hasPersistedKeys()
307
+ ? DEFAULT_SESSION_PERSIST
308
+ : DEFAULT_SESSION_TTL
309
+ if (clock() - this.cookieIssuedAt > maxAgeMs / 2) {
310
+ this.cookieRefreshDue = true
311
+ }
312
+ }
313
+
314
+ public get isModified() {
315
+ return this.modified
316
+ }
317
+ public get accessedAt() {
318
+ return Session.cache.getAccessedAt(sessionKey(this.id)) ?? Date.now()
319
+ }
320
+ public get persistedKeys() {
321
+ return Array.from(this.persistKeys)
322
+ }
323
+
324
+ public hasPersistedKeys() {
325
+ return this.persistKeys.size > 0
326
+ }
327
+
328
+ public hasData() {
329
+ return Object.keys(this.rawData).length > 0
330
+ }
331
+
332
+ public isExpired(): boolean {
333
+ const accessed = this.accessedAt
334
+ return this.hasPersistedKeys()
335
+ ? Date.now() - accessed > DEFAULT_SESSION_PERSIST
336
+ : Date.now() - accessed > DEFAULT_SESSION_TTL
337
+ }
338
+
339
+ public persist(key: keyof T | (string & {}), state: boolean = true): this {
340
+ this.persistKeys[state ? 'add' : 'delete'](key as string)
341
+ this.modified = true
342
+
343
+ if (this.persistKeys.size > 0) {
344
+ Session.cache.set(sessionKey(this.id), this)
345
+ }
346
+
347
+ return this
348
+ }
349
+
350
+ /**
351
+ * Mint a new session id, carrying the data and persisted keys across, and
352
+ * drop the entry under the old one.
353
+ *
354
+ * This is the missing anti-fixation primitive. `reset()` clears data but
355
+ * keeps the id, so an app that does `req.session.set('userId', …)` on login
356
+ * finishes authentication on the *same* id the visitor arrived with —
357
+ * including one an attacker planted. Call it at the privilege boundary:
358
+ *
359
+ * ```ts no-check — illustrative call site, not a compiled example
360
+ * req.session.regenerate().set('userId', user.id, true)
361
+ * ```
362
+ *
363
+ * Marking the session modified is what re-issues the cookie: `getCookie`
364
+ * emits `sId=` from the current id and only when `modified` is set, and it is
365
+ * the same object `req.session` already holds, so the rest of the request
366
+ * sees the new id.
367
+ *
368
+ * `createdAt` is deliberately preserved — the session continues, only its
369
+ * bearer token is replaced.
370
+ */
371
+ public regenerate(): this {
372
+ const previous = this.id
373
+ // `id` is readonly to callers; rotating it is the one legitimate write.
374
+ ;(this as { id: string }).id = newSessionId()
375
+
376
+ Session.cache.delete(sessionKey(previous))
377
+ Session.cache.set(sessionKey(this.id), this)
378
+ this.modified = true
379
+
380
+ return this
381
+ }
382
+
383
+ public reset(full = false): void {
384
+ if (full) this.persistKeys.clear()
385
+
386
+ for (const key of Object.keys(this.rawData)) {
387
+ if (this.persistKeys.has(key)) continue
388
+ delete this.rawData[key as keyof T]
389
+ }
390
+
391
+ if (!this.hasPersistedKeys()) {
392
+ Session.cache.delete(sessionKey(this.id))
393
+ this.modified = false
394
+ // A pending half-life refresh would make `getCookie` re-store the
395
+ // session this branch just deleted.
396
+ this.cookieRefreshDue = false
397
+ } else {
398
+ Session.cache.set(sessionKey(this.id), this)
399
+ }
400
+ }
401
+
402
+ get<K extends keyof T>(key: K): T[K] | undefined
403
+ get<K extends keyof T>(key: K, defaultValue: T[K]): T[K]
404
+ get<R = string>(key: string & {}): R | undefined
405
+ get(key: string & {}, defaultValue: boolean): boolean
406
+ get(key: string & {}, defaultValue: number): number
407
+ get(key: string & {}, defaultValue: string): string
408
+ get<R = string>(key: string & {}, defaultValue: R): R
409
+ public get(key: any, defaultValue?: any): any {
410
+ return (this.rawData[key] ?? defaultValue) as any
411
+ }
412
+
413
+ set<K extends keyof T>(key: K, value: T[K], persist?: boolean): this
414
+ set<V = any>(key: string & {}, value: V, persist?: boolean): this
415
+ public set(key: any, value: any, persist = false): this {
416
+ ;(this.data as any)[key] = value
417
+ if (persist) this.persist(key, true)
418
+ return this
419
+ }
420
+
421
+ public delete(key: TK, persist?: boolean): this {
422
+ if (!key) return this
423
+
424
+ if (persist) this.persist(key, false)
425
+ delete this.data[key]
426
+ return this
427
+ }
428
+
429
+ public bind(response?: Response) {
430
+ return Session.bind({ session: this } as any, response)
431
+ }
432
+
433
+ public destroy(): void {
434
+ Session.delete(this.id)
435
+ }
436
+
437
+ public toJSON() {
438
+ return {
439
+ id: this.id,
440
+ createdAt: this.createdAt,
441
+ accessedAt: this.accessedAt,
442
+ cookieIssuedAt: this.cookieIssuedAt,
443
+ persistKeys: Array.from(this.persistKeys),
444
+ data: { ...this.rawData },
445
+ }
446
+ }
447
+
448
+ static async *[Symbol.asyncIterator]() {
449
+ for (const [, sess] of Session.entries()) {
450
+ yield sess
451
+ }
452
+ }
453
+
454
+ static async get(sessid: string): Promise<Session<any> | undefined> {
455
+ return await Session.cache.get(sessionKey(sessid))
456
+ }
457
+
458
+ /**
459
+ * Every enumeration below filters to the current host and yields the bare
460
+ * session id, not the cache key — so `Session.keys()` still returns ids the
461
+ * dashboard can hand back to `Session.get` / `Session.delete`.
462
+ */
463
+ static *entries(): IterableIterator<[string, Session<any>]> {
464
+ const scope = sessionScope()
465
+ for (const [key, sess] of Session.cache.entries()) {
466
+ if (!inScope(key, scope)) continue
467
+ yield [key.slice(scope.length), sess]
468
+ }
469
+ }
470
+
471
+ static *values(): IterableIterator<Session<any>> {
472
+ for (const [, sess] of Session.entries()) yield sess
473
+ }
474
+
475
+ static *keys(): IterableIterator<string> {
476
+ for (const [id] of Session.entries()) yield id
477
+ }
478
+
479
+ static list(options: {
480
+ search?: string
481
+ page: number
482
+ pageSize: number
483
+ sortBy: string
484
+ sortOrder: 'ASC' | 'DESC'
485
+ }) {
486
+ // Fast path: with no `hosts` configured every key is already in the default
487
+ // bucket, so the cache's own SQL paging is correctly scoped and there is no
488
+ // reason to give it up.
489
+ //
490
+ // The check is on `hosts` and not on the scope, deliberately: `hostKey`
491
+ // collapses to '' in the unconfigured case too, so the scope alone cannot
492
+ // tell the two situations apart. This method used to compute one anyway and
493
+ // never read it — scoping lives in `Session.entries()`, which the slow path
494
+ // below goes through.
495
+ const hosts = Bakery.config.hosts
496
+ if (!hosts || Object.keys(hosts).length === 0) {
497
+ return Session.cache.search(options)
498
+ }
499
+
500
+ // Scoped path: `TieredCache.search` pages in SQL across the whole table,
501
+ // which is every tenant's sessions, and it has no key-prefix predicate — so
502
+ // the filter has to happen before paging, in JS. Bounded by the session
503
+ // table, which the pruner holds down to live sessions.
504
+ const needle = options.search?.trim().toLowerCase() || ''
505
+ const matched: Session<any>[] = []
506
+ for (const [id, sess] of Session.entries()) {
507
+ if (
508
+ needle &&
509
+ !id.toLowerCase().includes(needle) &&
510
+ !JSON.stringify(sess).toLowerCase().includes(needle)
511
+ ) {
512
+ continue
513
+ }
514
+ matched.push(sess)
515
+ }
516
+
517
+ const dir = options.sortOrder === 'ASC' ? 1 : -1
518
+ const rank = (s: Session<any>) =>
519
+ options.sortBy === 'id'
520
+ ? s.id
521
+ : options.sortBy === 'keys'
522
+ ? s.persistedKeys.length
523
+ : s.accessedAt
524
+ matched.sort((a, b) => {
525
+ const [ra, rb] = [rank(a), rank(b)]
526
+ if (ra < rb) return -dir
527
+ if (ra > rb) return dir
528
+ return a.id < b.id ? -dir : a.id > b.id ? dir : 0
529
+ })
530
+
531
+ const totalRows = matched.length
532
+ const totalPages = Math.max(1, Math.ceil(totalRows / options.pageSize))
533
+ const page = Math.min(options.page, totalPages)
534
+ const offset = Math.max(0, (page - 1) * options.pageSize)
535
+
536
+ return {
537
+ rows: matched.slice(offset, offset + options.pageSize),
538
+ totalRows,
539
+ page,
540
+ pageSize: options.pageSize,
541
+ totalPages,
542
+ }
543
+ }
544
+ }
545
+
546
+ const sessionPruneTimer = setInterval(
547
+ function cleanUpSessions() {
548
+ Session.cache.prune(DEFAULT_SESSION_TTL, '$.persistKeys')
549
+ Session.cache.prune(DEFAULT_SESSION_PERSIST)
550
+ },
551
+ 1000 * 60 * 15, // prune every 15 minutes
552
+ )
553
+
554
+ Bakery.onShutdown(() => {
555
+ clearInterval(sessionPruneTimer)
556
+ })
@@ -0,0 +1,63 @@
1
+ import type { MapOf } from './types'
2
+
3
+ /**
4
+ * Ambient types shared by the server and the browser runtime.
5
+ *
6
+ * These used to be declared twice, verbatim, in `global.d.ts` and
7
+ * `client/globals.d.ts`. Nothing caught it: `skipLibCheck: true` suppresses
8
+ * errors *inside* .d.ts files — including this project's own — so four TS2300
9
+ * duplicate-identifier errors sat there invisibly, and every tsconfig excluded
10
+ * `src/client/**` on top of that.
11
+ *
12
+ * Convention 5 is about exactly this: write it once, at the lowest layer that
13
+ * can hold it. `isomorphic.test.ts` pins the same rule for shared *values*
14
+ * because a second copy reappeared there once before; this is the type-level
15
+ * equivalent, and `tests/conventions.test.ts` now fails if a global name is
16
+ * declared in two ambient files again.
17
+ *
18
+ * `MapOf` and `Wrapped` have since moved out of the global scope entirely,
19
+ * into `types.d.ts` — their names are generic enough to collide with a
20
+ * consuming app's own. What stays global here is framework-specific enough
21
+ * not to, and is needed by browser globals that are bound at runtime rather
22
+ * than imported.
23
+ *
24
+ * Anything here must make sense in a browser with no Bun and no server config.
25
+ */
26
+
27
+ declare global {
28
+ /** The one JSON envelope — see convention 7. */
29
+ type JsonResponse<T = any> = {
30
+ time: number
31
+ status: number
32
+ message: string
33
+ data?: T
34
+ }
35
+
36
+ type ISFunction = {
37
+ (value: any, type: 'string'): value is string
38
+ (value: any, type: 'number'): value is number
39
+ (value: any, type: 'boolean'): value is boolean
40
+ (value: any, type: 'bigint'): value is bigint
41
+ (value: any, type: 'symbol'): value is symbol
42
+ (value: any, type: 'object'): value is Record<string, any>
43
+ (value: any, type: 'array'): value is any[]
44
+ (value: any, type: 'null'): value is null
45
+ (value: any, type: 'undefined'): value is undefined
46
+ // `Function` is the type being narrowed to, and there is no narrower
47
+ // spelling of "callable" a predicate can promise. `noBannedTypes` is off
48
+ // repo-wide, so this needs no suppression — see MONOREPO.md for why.
49
+ (value: any, type: 'function'): value is Function
50
+ (value: any, type?: string): boolean
51
+ string(value: any): value is string
52
+ number(value: any): value is number
53
+ boolean(value: any): value is boolean
54
+ bigint(value: any): value is bigint
55
+ symbol(value: any): value is symbol
56
+ object(value: any): value is MapOf<any>
57
+ array(value: any): value is any[]
58
+ null(value: any): value is null
59
+ undefined(value: any): value is undefined
60
+ // `Function` again, same reason as the overload above.
61
+ function(value: any): value is Function
62
+ }
63
+ }