@uniweb/kit 0.9.42 → 0.9.43

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.9.42",
3
+ "version": "0.9.43",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -40,7 +40,7 @@
40
40
  "fuse.js": "^7.0.0",
41
41
  "shiki": "^3.0.0",
42
42
  "tailwind-merge": "^3.6.0",
43
- "@uniweb/core": "0.7.32",
43
+ "@uniweb/core": "0.7.33",
44
44
  "@uniweb/scene": "0.1.2"
45
45
  },
46
46
  "peerDependencies": {
@@ -22,8 +22,12 @@ import { emptyResult } from './result.js'
22
22
  const indexCache = new Map()
23
23
  const fuseCache = new Map()
24
24
 
25
- const STORAGE_VERSION = 'v1'
26
- const STORAGE_PREFIX = `uniweb:search:${STORAGE_VERSION}:`
25
+ // Bumped to v2 when stored entries gained a validator. The version is part of
26
+ // the key, so every v1 entry — which had no way to be revalidated and could
27
+ // outlive any number of rebuilds — is orphaned rather than trusted.
28
+ const STORAGE_VERSION = 'v2'
29
+ const STORAGE_PREFIX = 'uniweb:search:'
30
+ const STORAGE_KEY = (cacheKey) => `${STORAGE_PREFIX}${STORAGE_VERSION}:${cacheKey}`
27
31
 
28
32
  /**
29
33
  * Default Fuse.js options optimized for site search
@@ -63,14 +67,12 @@ function loadFromStorage(cacheKey) {
63
67
  const storage = getStorage()
64
68
  if (!storage) return null
65
69
 
66
- const raw = storage.getItem(`${STORAGE_PREFIX}${cacheKey}`)
70
+ const raw = storage.getItem(STORAGE_KEY(cacheKey))
67
71
  if (!raw) return null
68
72
 
69
73
  try {
70
74
  const parsed = JSON.parse(raw)
71
- if (Array.isArray(parsed.entries)) {
72
- return parsed
73
- }
75
+ if (Array.isArray(parsed.payload?.entries)) return parsed
74
76
  return null
75
77
  } catch {
76
78
  return null
@@ -78,21 +80,52 @@ function loadFromStorage(cacheKey) {
78
80
  }
79
81
 
80
82
  /**
81
- * Save index to localStorage
83
+ * Save index to localStorage, together with the validator that lets a later
84
+ * load ask the server whether it is still current.
85
+ *
82
86
  * @param {string} cacheKey - Cache key
83
87
  * @param {Object} payload - Index data
88
+ * @param {{etag?: string|null, lastModified?: string|null}} [validator]
84
89
  */
85
- function saveToStorage(cacheKey, payload) {
90
+ function saveToStorage(cacheKey, payload, validator = {}) {
86
91
  const storage = getStorage()
87
92
  if (!storage) return
88
93
 
89
94
  try {
90
- storage.setItem(`${STORAGE_PREFIX}${cacheKey}`, JSON.stringify(payload))
95
+ storage.setItem(
96
+ STORAGE_KEY(cacheKey),
97
+ JSON.stringify({
98
+ payload,
99
+ etag: validator.etag || null,
100
+ lastModified: validator.lastModified || null,
101
+ })
102
+ )
91
103
  } catch {
92
104
  // Ignore quota errors
93
105
  }
94
106
  }
95
107
 
108
+ /**
109
+ * Drop entries written by an older storage schema.
110
+ *
111
+ * A search index runs to hundreds of kilobytes, so an orphaned one is a real
112
+ * bite out of a origin's storage quota rather than a tidiness issue.
113
+ */
114
+ function pruneOldVersions() {
115
+ const storage = getStorage()
116
+ if (!storage) return
117
+
118
+ const current = `${STORAGE_PREFIX}${STORAGE_VERSION}:`
119
+ const stale = []
120
+ for (let i = 0; i < storage.length; i++) {
121
+ const key = storage.key(i)
122
+ if (key?.startsWith(STORAGE_PREFIX) && !key.startsWith(current)) stale.push(key)
123
+ }
124
+ stale.forEach((key) => {
125
+ try { storage.removeItem(key) } catch { /* ignore */ }
126
+ })
127
+ }
128
+
96
129
  /**
97
130
  * Load search index for a locale
98
131
  * @param {string} indexUrl - URL to fetch the index from
@@ -104,32 +137,72 @@ function saveToStorage(cacheKey, payload) {
104
137
  export async function loadSearchIndex(indexUrl, options = {}) {
105
138
  const { cacheKey = indexUrl, useStorage = true } = options
106
139
 
107
- // Check memory cache first
140
+ // Memory cache: scoped to one page load, where the index cannot change
141
+ // underneath us. This is the only cache read that needs no validation.
108
142
  if (indexCache.has(cacheKey)) {
109
143
  return indexCache.get(cacheKey)
110
144
  }
111
145
 
112
- // Check localStorage cache
113
- if (useStorage) {
114
- const cached = loadFromStorage(cacheKey)
146
+ if (useStorage) pruneOldVersions()
147
+ const cached = useStorage ? loadFromStorage(cacheKey) : null
148
+
149
+ // ALWAYS ask the server, even holding a stored copy.
150
+ //
151
+ // Returning the stored index unconditionally — what this did before — has no
152
+ // expiry and no way to notice a rebuild, so a visitor who searched once kept
153
+ // answering from that index for as long as the entry survived. A redeploy
154
+ // did not dislodge it.
155
+ //
156
+ // It also collided across sites. The key is the index URL, which is
157
+ // `/search-index.json` for every Uniweb project, and localStorage is scoped
158
+ // to an origin — so two projects sharing a dev port shared one entry, and a
159
+ // search on one could return the other's pages. Revalidating settles that
160
+ // too: a different server answers 200 with its own index and replaces it.
161
+ const headers = {}
162
+ if (cached?.etag) headers['If-None-Match'] = cached.etag
163
+ else if (cached?.lastModified) headers['If-Modified-Since'] = cached.lastModified
164
+
165
+ let response
166
+ try {
167
+ // `no-cache` = revalidate, not "don't cache". `force-cache` (the previous
168
+ // value) told the browser to prefer any stored response regardless of age,
169
+ // which defeated revalidation a second time over.
170
+ response = await fetch(indexUrl, { headers, cache: 'no-cache' })
171
+ } catch (error) {
172
+ // Offline or unreachable. A stored index is better than no search at all,
173
+ // and this is the one path where serving it unvalidated is the right call.
115
174
  if (cached) {
116
- indexCache.set(cacheKey, cached)
117
- return cached
175
+ indexCache.set(cacheKey, cached.payload)
176
+ return cached.payload
118
177
  }
178
+ throw error
179
+ }
180
+
181
+ // Unchanged since we stored it — the whole point of keeping the validator.
182
+ if (response.status === 304 && cached) {
183
+ indexCache.set(cacheKey, cached.payload)
184
+ return cached.payload
119
185
  }
120
186
 
121
- // Fetch from server
122
- const response = await fetch(indexUrl, { cache: 'force-cache' })
123
187
  if (!response.ok) {
188
+ if (cached) {
189
+ indexCache.set(cacheKey, cached.payload)
190
+ return cached.payload
191
+ }
124
192
  throw new Error(`Failed to load search index: ${response.status}`)
125
193
  }
126
194
 
127
195
  const payload = await response.json()
128
196
 
129
- // Cache the result
130
197
  indexCache.set(cacheKey, payload)
131
198
  if (useStorage) {
132
- saveToStorage(cacheKey, payload)
199
+ // A host that sends no validator still gets stored — it just costs a full
200
+ // fetch next time instead of a 304. What it never does is get served as
201
+ // though it were known to be current.
202
+ saveToStorage(cacheKey, payload, {
203
+ etag: response.headers?.get?.('etag'),
204
+ lastModified: response.headers?.get?.('last-modified'),
205
+ })
133
206
  }
134
207
 
135
208
  return payload
@@ -145,7 +218,7 @@ export function clearSearchCache(cacheKey) {
145
218
  fuseCache.delete(cacheKey)
146
219
  const storage = getStorage()
147
220
  if (storage) {
148
- storage.removeItem(`${STORAGE_PREFIX}${cacheKey}`)
221
+ storage.removeItem(STORAGE_KEY(cacheKey))
149
222
  }
150
223
  } else {
151
224
  indexCache.clear()