@bitstillery/mithril 3.6.0 → 3.7.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/store.ts +95 -8
package/package.json
CHANGED
package/store.ts
CHANGED
|
@@ -118,6 +118,10 @@ function merge_deep(target: any, ...sources: any[]): any {
|
|
|
118
118
|
|
|
119
119
|
const DEFAULT_LOOKUP_VERIFY_INTERVAL = 1000 * 10 // 10 seconds
|
|
120
120
|
const DEFAULT_LOOKUP_TTL = 1000 * 60 * 60 * 24 // 1 day
|
|
121
|
+
const DEFAULT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year, in seconds
|
|
122
|
+
// Browsers cap a single cookie at ~4KB; stay well under so we never silently drop a write
|
|
123
|
+
// or bloat every request. The cookie tier is for small, render-affecting preferences only.
|
|
124
|
+
const MAX_COOKIE_BYTES = 3500
|
|
121
125
|
|
|
122
126
|
// Counter for generating unique store instance names
|
|
123
127
|
let storeInstanceCounter = 0
|
|
@@ -131,6 +135,10 @@ let storeInstanceCounter = 0
|
|
|
131
135
|
* - temporary: not persisted (resets on reload)
|
|
132
136
|
* - tab: sessionStorage (survives page reloads, clears when tab closes)
|
|
133
137
|
* - session: server-side session storage (optional, off by default; requires backend, hydrated via SSR)
|
|
138
|
+
* - cookie: a single JSON cookie (optional, off by default). Unlike localStorage, a cookie is sent
|
|
139
|
+
* on the SSR document request, so the server can render with these values and avoid a hydration
|
|
140
|
+
* flash. For small, render-affecting preferences only (≤MAX_COOKIE_BYTES) — never large or
|
|
141
|
+
* growing data (it ships on every request).
|
|
134
142
|
*/
|
|
135
143
|
export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
136
144
|
private stateInstance: State<T>
|
|
@@ -139,17 +147,30 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
139
147
|
temporary: {} as Partial<T>,
|
|
140
148
|
tab: {} as Partial<T>,
|
|
141
149
|
session: {} as Partial<T>,
|
|
150
|
+
cookie: {} as Partial<T>,
|
|
142
151
|
}
|
|
143
152
|
private lookup_verify_interval: number | null = null
|
|
144
153
|
private lookup_ttl: number
|
|
145
154
|
private computedPropertiesSetup?: () => void
|
|
146
155
|
private storageKey: string
|
|
147
156
|
private tabStorageKey: string
|
|
148
|
-
|
|
149
|
-
|
|
157
|
+
private cookieKey: string
|
|
158
|
+
private cookieMaxAge: number
|
|
159
|
+
|
|
160
|
+
constructor(
|
|
161
|
+
options: {
|
|
162
|
+
lookup_ttl?: number
|
|
163
|
+
storageKey?: string
|
|
164
|
+
tabStorageKey?: string
|
|
165
|
+
cookieKey?: string
|
|
166
|
+
cookieMaxAge?: number
|
|
167
|
+
} = {lookup_ttl: DEFAULT_LOOKUP_TTL},
|
|
168
|
+
) {
|
|
150
169
|
this.lookup_ttl = options.lookup_ttl || DEFAULT_LOOKUP_TTL
|
|
151
170
|
this.storageKey = options.storageKey ?? 'store'
|
|
152
171
|
this.tabStorageKey = options.tabStorageKey ?? this.storageKey
|
|
172
|
+
this.cookieKey = options.cookieKey ?? 'store_prefs'
|
|
173
|
+
this.cookieMaxAge = options.cookieMaxAge ?? DEFAULT_COOKIE_MAX_AGE
|
|
153
174
|
// Initialize with empty state, will be loaded later (ADR-0013: defer computeds until ready() is called)
|
|
154
175
|
const instanceName = `store.instance.${storeInstanceCounter++}`
|
|
155
176
|
this.stateInstance = state({} as T, instanceName, {deferComputed: true})
|
|
@@ -275,7 +296,13 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
275
296
|
}
|
|
276
297
|
}
|
|
277
298
|
|
|
278
|
-
load(
|
|
299
|
+
load(
|
|
300
|
+
saved: Partial<T>,
|
|
301
|
+
temporary: Partial<T>,
|
|
302
|
+
tab: Partial<T> = {} as Partial<T>,
|
|
303
|
+
session: Partial<T> = {} as Partial<T>,
|
|
304
|
+
cookie: Partial<T> = {} as Partial<T>,
|
|
305
|
+
) {
|
|
279
306
|
const restored_state = {
|
|
280
307
|
tab: this.get_tab_storage(this.tabStorageKey),
|
|
281
308
|
store: this.get(this.storageKey),
|
|
@@ -286,6 +313,7 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
286
313
|
temporary,
|
|
287
314
|
tab,
|
|
288
315
|
session,
|
|
316
|
+
cookie,
|
|
289
317
|
}
|
|
290
318
|
|
|
291
319
|
try {
|
|
@@ -321,6 +349,12 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
321
349
|
// Session state comes from server (SSR), not localStorage
|
|
322
350
|
const final_state = merge_deep(temp_state, copy_object(session))
|
|
323
351
|
|
|
352
|
+
// Merge cookie state last so it wins for its keys. On the client we read the actual cookie
|
|
353
|
+
// from document.cookie; during SSR there is no document, so the caller passes the
|
|
354
|
+
// request-derived values as the `cookie` template (same pattern as session/sessionTemplate).
|
|
355
|
+
const cookie_state = merge_deep(copy_object(cookie), this.get_cookie(this.cookieKey))
|
|
356
|
+
merge_deep(final_state, cookie_state)
|
|
357
|
+
|
|
324
358
|
// Merge templates (including computed properties) into "merged initial state"
|
|
325
359
|
// This will be stored in registry so computed properties can be automatically restored
|
|
326
360
|
// Use copy_object_preserve_functions to deep copy while preserving functions
|
|
@@ -331,7 +365,15 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
331
365
|
const mergedInitialTab = tab && Object.keys(tab).length > 0 ? {tab: copy_object_preserve_functions(tab)} : {}
|
|
332
366
|
// Session template is merged directly (no nesting needed, structure matches final_state)
|
|
333
367
|
const mergedInitialSession = copy_object_preserve_functions(session)
|
|
334
|
-
|
|
368
|
+
// Cookie template is merged directly too (structure matches final_state)
|
|
369
|
+
const mergedInitialCookie = copy_object_preserve_functions(cookie)
|
|
370
|
+
const mergedInitial = merge_deep(
|
|
371
|
+
mergedInitialSaved,
|
|
372
|
+
mergedInitialTemporary,
|
|
373
|
+
mergedInitialTab,
|
|
374
|
+
mergedInitialSession,
|
|
375
|
+
mergedInitialCookie,
|
|
376
|
+
)
|
|
335
377
|
|
|
336
378
|
// Update registry entry to store merged templates as "initial" state
|
|
337
379
|
// This allows deserializeAllStates() to automatically restore computed properties
|
|
@@ -365,19 +407,21 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
365
407
|
}
|
|
366
408
|
|
|
367
409
|
/**
|
|
368
|
-
* Persist state to storage. When no options are passed, saves to localStorage (saved)
|
|
369
|
-
* sessionStorage (tab)
|
|
410
|
+
* Persist state to storage. When no options are passed, saves to localStorage (saved),
|
|
411
|
+
* sessionStorage (tab), and the cookie tier (when a cookie template is registered). Session
|
|
412
|
+
* (server-side) is off by default; pass { session: true } to persist it.
|
|
370
413
|
*/
|
|
371
|
-
async save(options?: {saved?: boolean; tab?: boolean; session?: boolean}): Promise<void> {
|
|
414
|
+
async save(options?: {saved?: boolean; tab?: boolean; session?: boolean; cookie?: boolean}): Promise<void> {
|
|
372
415
|
// Skip saving during SSR (server-side rendering in Bun)
|
|
373
416
|
// On the server, there's no localStorage/sessionStorage and no need to persist state
|
|
374
417
|
if (globalThis.__SSR_MODE__) {
|
|
375
418
|
return
|
|
376
419
|
}
|
|
377
420
|
|
|
378
|
-
// Default: write to localStorage and
|
|
421
|
+
// Default: write to localStorage, sessionStorage and cookie when no options; session is opt-in
|
|
379
422
|
const writeLocalStorage = options?.saved ?? options === undefined
|
|
380
423
|
const writeSessionStorage = options?.tab ?? options === undefined
|
|
424
|
+
const writeCookie = options?.cookie ?? options === undefined
|
|
381
425
|
const writeSessionApi = options?.session === true
|
|
382
426
|
|
|
383
427
|
const statePlain = serializeStore(this.stateInstance)
|
|
@@ -393,6 +437,12 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
393
437
|
this.persist_lookup_to_local_storage(statePlain)
|
|
394
438
|
}
|
|
395
439
|
|
|
440
|
+
// Write cookie-backed preferences (small, SSR-visible). Only when a cookie template is
|
|
441
|
+
// registered, so consumers that don't opt in never get an empty cookie written.
|
|
442
|
+
if (writeCookie && this.templates.cookie && Object.keys(this.templates.cookie).length > 0) {
|
|
443
|
+
this.set_cookie(this.cookieKey, this.blueprint(statePlain, copy_object(this.templates.cookie)))
|
|
444
|
+
}
|
|
445
|
+
|
|
396
446
|
// Write to sessionStorage (tab-scoped, cleared when tab closes)
|
|
397
447
|
if (writeSessionStorage && this.templates.tab) {
|
|
398
448
|
const tabState = (this.stateInstance as any).tab
|
|
@@ -476,6 +526,43 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
476
526
|
}
|
|
477
527
|
}
|
|
478
528
|
|
|
529
|
+
/**
|
|
530
|
+
* Read and parse the JSON cookie tier. Returns {} when there is no document (SSR), the cookie
|
|
531
|
+
* is absent, or it cannot be parsed. During SSR the request cookie is injected via the `cookie`
|
|
532
|
+
* template in load() instead, so this only does real work in the browser.
|
|
533
|
+
*/
|
|
534
|
+
get_cookie(key: string): Record<string, any> {
|
|
535
|
+
if (typeof document === 'undefined') return {}
|
|
536
|
+
try {
|
|
537
|
+
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${key}=([^;]*)`))
|
|
538
|
+
if (!match) return {}
|
|
539
|
+
const parsed = JSON.parse(decodeURIComponent(match[1]))
|
|
540
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
541
|
+
} catch {
|
|
542
|
+
return {}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Write the cookie tier as a single JSON cookie. Skips the write (with a warning) when the
|
|
548
|
+
* serialized value would exceed MAX_COOKIE_BYTES, so we never silently corrupt requests.
|
|
549
|
+
*/
|
|
550
|
+
set_cookie(key: string, item: object): void {
|
|
551
|
+
if (typeof document === 'undefined') return
|
|
552
|
+
try {
|
|
553
|
+
const value = encodeURIComponent(JSON.stringify(item))
|
|
554
|
+
if (value.length > MAX_COOKIE_BYTES) {
|
|
555
|
+
console.warn(
|
|
556
|
+
`[store] cookie '${key}' is ${value.length} bytes, over the ${MAX_COOKIE_BYTES} limit; skipping write. Keep the cookie tier small.`,
|
|
557
|
+
)
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
document.cookie = `${key}=${value}; Path=/; SameSite=Lax; Max-Age=${this.cookieMaxAge}`
|
|
561
|
+
} catch (err) {
|
|
562
|
+
console.error('Cannot write cookie; continue without.', err)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
479
566
|
set_tab(key: string, item: object): void {
|
|
480
567
|
if (typeof window === 'undefined') return
|
|
481
568
|
try {
|