Skip to content

Commit 5b162cf

Browse files
committed
chore (change-history):
- harden localStorage writes and snapshot retention - catch quota/private-mode failures on setItem - cap full-spec snapshots by size (512KB) and TTL (30 days) so history data cannot grow or linger unbounded.
1 parent f683833 commit 5b162cf

4 files changed

Lines changed: 262 additions & 11 deletions

File tree

src/core/config/defaults.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const defaultOptions = Object.freeze({
1919
persistAuthorization: false,
2020
changeHistory: true,
2121
changeHistoryMaxEntries: 20,
22+
// --- soft cap for the full-spec snapshot persisted to localStorage (UTF-8 bytes)
23+
changeHistoryMaxSnapshotBytes: 512 * 1024,
24+
// --- drop change-history data older than this (30 days)
25+
changeHistoryTtlMs: 30 * 24 * 60 * 60 * 1000,
2226
configs: {},
2327
displayOperationId: false,
2428
displayRequestDuration: false,

src/core/plugins/change-history/fn.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,3 +603,102 @@ export function formatChangeSummary(change) {
603603
export const STORAGE_PREFIX = "swagger-ui-change-history"
604604
export const SNAPSHOT_PREFIX = "swagger-ui-change-snapshot"
605605
export const VIEWED_PREFIX = "swagger-ui-change-history-viewed"
606+
607+
/** Soft cap for a single persisted snapshot (UTF-8 bytes). */
608+
export const DEFAULT_MAX_SNAPSHOT_BYTES = 512 * 1024
609+
610+
/** Drop history/snapshots older than this (30 days). */
611+
export const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000
612+
613+
export function getSerializedByteLength(value) {
614+
const text = typeof value === "string" ? value : JSON.stringify(value)
615+
if (typeof TextEncoder !== "undefined") {
616+
return new TextEncoder().encode(text).length
617+
}
618+
return text.length
619+
}
620+
621+
export function wrapSnapshot(spec, savedAt = Date.now()) {
622+
return { savedAt, spec }
623+
}
624+
625+
export function wrapOversizedSnapshotMarker(specHash, savedAt = Date.now()) {
626+
return { savedAt, oversized: true, specHash }
627+
}
628+
629+
export function unwrapSnapshot(value, { ttlMs, now = Date.now() } = {}) {
630+
if (!value || typeof value !== "object") {
631+
return null
632+
}
633+
634+
// Oversized markers intentionally omit the full spec
635+
if (value.oversized) {
636+
return null
637+
}
638+
639+
const isWrapped =
640+
Object.prototype.hasOwnProperty.call(value, "spec") &&
641+
Object.prototype.hasOwnProperty.call(value, "savedAt")
642+
643+
const spec = isWrapped ? value.spec : value
644+
const savedAt = isWrapped ? value.savedAt : null
645+
646+
if (!spec || typeof spec !== "object") {
647+
return null
648+
}
649+
650+
if (
651+
savedAt != null &&
652+
ttlMs != null &&
653+
ttlMs > 0 &&
654+
now - savedAt > ttlMs
655+
) {
656+
return null
657+
}
658+
659+
return { spec, savedAt }
660+
}
661+
662+
export function isOversizedSnapshotMarker(value, { ttlMs, now = Date.now() } = {}) {
663+
if (!value || typeof value !== "object" || !value.oversized) {
664+
return false
665+
}
666+
667+
if (
668+
value.savedAt != null &&
669+
ttlMs != null &&
670+
ttlMs > 0 &&
671+
now - value.savedAt > ttlMs
672+
) {
673+
return false
674+
}
675+
676+
return true
677+
}
678+
679+
export function filterHistoryByTtl(history, ttlMs, now = Date.now()) {
680+
if (!Array.isArray(history)) {
681+
return []
682+
}
683+
684+
if (!ttlMs || ttlMs <= 0) {
685+
return history
686+
}
687+
688+
return history.filter(
689+
(entry) =>
690+
entry &&
691+
typeof entry.timestamp === "number" &&
692+
now - entry.timestamp <= ttlMs
693+
)
694+
}
695+
696+
export function canPersistSnapshot(spec, maxBytes, savedAt = Date.now()) {
697+
if (!maxBytes || maxBytes <= 0) {
698+
return true
699+
}
700+
701+
return (
702+
getSerializedByteLength(wrapSnapshot(spec, savedAt)) <= maxBytes
703+
)
704+
}

src/core/plugins/change-history/spec-actions.js

Lines changed: 101 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import {
2+
canPersistSnapshot,
23
compareSpecs,
4+
DEFAULT_MAX_SNAPSHOT_BYTES,
5+
DEFAULT_TTL_MS,
6+
filterHistoryByTtl,
37
getStorageKey,
48
hashSpec,
9+
isOversizedSnapshotMarker,
510
SNAPSHOT_PREFIX,
611
STORAGE_PREFIX,
12+
unwrapSnapshot,
713
VIEWED_PREFIX,
14+
wrapOversizedSnapshotMarker,
15+
wrapSnapshot,
816
} from "./fn"
917

1018
function readJson(key, fallback) {
@@ -17,7 +25,27 @@ function readJson(key, fallback) {
1725
}
1826

1927
function writeJson(key, value) {
20-
localStorage.setItem(key, JSON.stringify(value))
28+
try {
29+
localStorage.setItem(key, JSON.stringify(value))
30+
return true
31+
} catch (err) {
32+
console.warn(
33+
"Swagger UI: unable to persist change history to localStorage",
34+
err
35+
)
36+
return false
37+
}
38+
}
39+
40+
function removeKey(key) {
41+
try {
42+
localStorage.removeItem(key)
43+
} catch (err) {
44+
console.warn(
45+
"Swagger UI: unable to remove change history from localStorage",
46+
err
47+
)
48+
}
2149
}
2250

2351
function getHistoryStorageKey(storageKey) {
@@ -32,6 +60,12 @@ function getViewedStorageKey(storageKey) {
3260
return `${VIEWED_PREFIX}:${storageKey}`
3361
}
3462

63+
function clearPersistedHistory(storageKey) {
64+
removeKey(getHistoryStorageKey(storageKey))
65+
removeKey(getSnapshotStorageKey(storageKey))
66+
removeKey(getViewedStorageKey(storageKey))
67+
}
68+
3569
export const recordSpecLoad = () => (system) => {
3670
const {
3771
specSelectors,
@@ -54,22 +88,51 @@ export const recordSpecLoad = () => (system) => {
5488
const storageKey = getStorageKey(url, spec)
5589
const specHash = hashSpec(spec)
5690
const maxEntries = configs.changeHistoryMaxEntries || 20
91+
const maxSnapshotBytes =
92+
configs.changeHistoryMaxSnapshotBytes ?? DEFAULT_MAX_SNAPSHOT_BYTES
93+
const ttlMs = configs.changeHistoryTtlMs ?? DEFAULT_TTL_MS
94+
const now = Date.now()
5795

5896
changeHistoryActions.setStorageKey(storageKey)
5997

6098
const historyKey = getHistoryStorageKey(storageKey)
6199
const snapshotKey = getSnapshotStorageKey(storageKey)
62100
const viewedKey = getViewedStorageKey(storageKey)
63101

64-
const existingHistory = readJson(historyKey, [])
65-
const previousSnapshot = readJson(snapshotKey, null)
102+
const existingHistory = filterHistoryByTtl(
103+
readJson(historyKey, []),
104+
ttlMs,
105+
now
106+
)
107+
const rawSnapshot = readJson(snapshotKey, null)
108+
const previousSnapshotPayload = unwrapSnapshot(rawSnapshot, {
109+
ttlMs,
110+
now,
111+
})
112+
const previousSnapshot = previousSnapshotPayload
113+
? previousSnapshotPayload.spec
114+
: null
66115
const lastViewedAt = readJson(viewedKey, 0)
116+
const oversizedMarker = isOversizedSnapshotMarker(rawSnapshot, {
117+
ttlMs,
118+
now,
119+
})
120+
121+
// Drop expired / invalid snapshot payloads from disk
122+
if (rawSnapshot && !previousSnapshotPayload && !oversizedMarker) {
123+
removeKey(snapshotKey)
124+
}
67125

68-
if (previousSnapshot && hashSpec(previousSnapshot) === specHash) {
126+
if (
127+
(previousSnapshot && hashSpec(previousSnapshot) === specHash) ||
128+
(oversizedMarker && rawSnapshot.specHash === specHash)
129+
) {
69130
changeHistoryActions.setHistory(existingHistory)
70131
changeHistoryActions.setUnseenChanges(
71132
existingHistory.some((entry) => entry.timestamp > lastViewedAt)
72133
)
134+
// Keep history pruned on disk even when the snapshot is unchanged
135+
writeJson(historyKey, existingHistory)
73136
return
74137
}
75138

@@ -81,8 +144,8 @@ export const recordSpecLoad = () => (system) => {
81144

82145
if (!previousSnapshot || changes.length) {
83146
const entry = {
84-
id: `${Date.now()}`,
85-
timestamp: Date.now(),
147+
id: `${now}`,
148+
timestamp: now,
86149
version: spec?.info?.version || null,
87150
title: spec?.info?.title || null,
88151
specHash,
@@ -92,7 +155,16 @@ export const recordSpecLoad = () => (system) => {
92155

93156
const nextHistory = [entry, ...existingHistory].slice(0, maxEntries)
94157
writeJson(historyKey, nextHistory)
95-
writeJson(snapshotKey, spec)
158+
159+
if (canPersistSnapshot(spec, maxSnapshotBytes, now)) {
160+
writeJson(snapshotKey, wrapSnapshot(spec, now))
161+
} else {
162+
console.warn(
163+
"Swagger UI: change-history snapshot exceeds size limit; skipping localStorage snapshot persistence"
164+
)
165+
// Keep a tiny marker so we do not re-baseline on every reload
166+
writeJson(snapshotKey, wrapOversizedSnapshotMarker(specHash, now))
167+
}
96168

97169
changeHistoryActions.setHistory(nextHistory)
98170

@@ -102,6 +174,7 @@ export const recordSpecLoad = () => (system) => {
102174
}
103175
} else {
104176
changeHistoryActions.setHistory(existingHistory)
177+
writeJson(historyKey, existingHistory)
105178
}
106179
}
107180

@@ -118,10 +191,29 @@ export const restoreHistory = () => (system) => {
118191
return
119192
}
120193

194+
const ttlMs = configs.changeHistoryTtlMs ?? DEFAULT_TTL_MS
195+
const now = Date.now()
121196
const historyKey = getHistoryStorageKey(storageKey)
197+
const snapshotKey = getSnapshotStorageKey(storageKey)
122198
const viewedKey = getViewedStorageKey(storageKey)
123-
const existingHistory = readJson(historyKey, [])
199+
const existingHistory = filterHistoryByTtl(
200+
readJson(historyKey, []),
201+
ttlMs,
202+
now
203+
)
124204
const lastViewedAt = readJson(viewedKey, 0)
205+
const rawSnapshot = readJson(snapshotKey, null)
206+
207+
if (
208+
rawSnapshot &&
209+
!unwrapSnapshot(rawSnapshot, { ttlMs, now }) &&
210+
!isOversizedSnapshotMarker(rawSnapshot, { ttlMs, now })
211+
) {
212+
removeKey(snapshotKey)
213+
}
214+
215+
// Persist pruned history so expired entries don't linger
216+
writeJson(historyKey, existingHistory)
125217

126218
changeHistoryActions.setHistory(existingHistory)
127219
changeHistoryActions.setUnseenChanges(
@@ -163,9 +255,7 @@ export const clearHistory = () => (system) => {
163255
return
164256
}
165257

166-
localStorage.removeItem(getHistoryStorageKey(storageKey))
167-
localStorage.removeItem(getSnapshotStorageKey(storageKey))
168-
localStorage.removeItem(getViewedStorageKey(storageKey))
258+
clearPersistedHistory(storageKey)
169259

170260
changeHistoryActions.setHistory([])
171261
changeHistoryActions.setUnseenChanges(false)

test/unit/core/plugins/change-history/fn.test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import {
2+
canPersistSnapshot,
23
compareSpecs,
4+
filterHistoryByTtl,
35
formatChangeSummary,
46
getStorageKey,
57
hashSpec,
8+
isOversizedSnapshotMarker,
69
resolveRefs,
710
stableStringify,
11+
unwrapSnapshot,
12+
wrapOversizedSnapshotMarker,
13+
wrapSnapshot,
814
} from "core/plugins/change-history/fn"
915

1016
describe("change-history fn", () => {
@@ -449,4 +455,56 @@ describe("change-history fn", () => {
449455
).toEqual('Schema "Pet": type changed (object → array)')
450456
})
451457
})
458+
459+
describe("snapshot retention helpers", () => {
460+
it("wraps and unwraps snapshots with savedAt", () => {
461+
const wrapped = wrapSnapshot(baseSpec, 1000)
462+
expect(wrapped).toEqual({ savedAt: 1000, spec: baseSpec })
463+
expect(unwrapSnapshot(wrapped, { ttlMs: 5000, now: 2000 })).toEqual({
464+
savedAt: 1000,
465+
spec: baseSpec,
466+
})
467+
})
468+
469+
it("supports legacy plain-spec snapshots", () => {
470+
expect(unwrapSnapshot(baseSpec, { ttlMs: 1, now: Date.now() })).toEqual({
471+
savedAt: null,
472+
spec: baseSpec,
473+
})
474+
})
475+
476+
it("expires wrapped snapshots past TTL", () => {
477+
const wrapped = wrapSnapshot(baseSpec, 1000)
478+
expect(unwrapSnapshot(wrapped, { ttlMs: 100, now: 2000 })).toBeNull()
479+
})
480+
481+
it("filters history entries older than TTL", () => {
482+
const history = [
483+
{ id: "1", timestamp: 1000 },
484+
{ id: "2", timestamp: 5000 },
485+
]
486+
expect(filterHistoryByTtl(history, 2000, 6000)).toEqual([
487+
{ id: "2", timestamp: 5000 },
488+
])
489+
})
490+
491+
it("rejects oversized snapshots and keeps a hash marker", () => {
492+
expect(canPersistSnapshot(baseSpec, 10, 1000)).toBe(false)
493+
expect(canPersistSnapshot(baseSpec, 1024 * 1024, 1000)).toBe(true)
494+
495+
const marker = wrapOversizedSnapshotMarker("abc", 1000)
496+
expect(marker).toEqual({
497+
savedAt: 1000,
498+
oversized: true,
499+
specHash: "abc",
500+
})
501+
expect(unwrapSnapshot(marker, { ttlMs: 5000, now: 2000 })).toBeNull()
502+
expect(isOversizedSnapshotMarker(marker, { ttlMs: 5000, now: 2000 })).toBe(
503+
true
504+
)
505+
expect(isOversizedSnapshotMarker(marker, { ttlMs: 100, now: 2000 })).toBe(
506+
false
507+
)
508+
})
509+
})
452510
})

0 commit comments

Comments
 (0)