@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/store.ts +95 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitstillery/mithril",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "description": "Mithril + Signals, Store and SSR",
5
5
  "license": "MIT",
6
6
  "author": "Bitstillery",
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
- constructor(options: {lookup_ttl?: number; storageKey?: string; tabStorageKey?: string} = {lookup_ttl: DEFAULT_LOOKUP_TTL}) {
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(saved: Partial<T>, temporary: Partial<T>, tab: Partial<T> = {} as Partial<T>, session: Partial<T> = {} as Partial<T>) {
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
- const mergedInitial = merge_deep(mergedInitialSaved, mergedInitialTemporary, mergedInitialTab, mergedInitialSession)
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) and
369
- * sessionStorage (tab). Session (server-side) is off by default; pass { session: true } to persist it.
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 sessionStorage when no options; session is opt-in
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 {