@cplieger/web-terminal-ui 5.2.2 → 5.3.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.
@@ -0,0 +1,316 @@
1
+ // A ready-made localStorage implementation of the scrollback-persistence seam.
2
+ //
3
+ // `persistScrollback` takes the storage rather than a switch, for reasons that
4
+ // belong to the library (see ScrollbackPersistence). That is the right boundary
5
+ // and it stays. But every consumer that wants the feature then writes the same
6
+ // forty lines, and four of them writing it four times is four places for the
7
+ // orphan sweep to be forgotten — which is the one part a consumer is most likely
8
+ // to skip, because nothing breaks when it is missing until the quota fills.
9
+ //
10
+ // So this is a supplied implementation, never a default: a consumer still has to
11
+ // pass it, which is what keeps the feature off unless someone decided it should
12
+ // be on.
13
+ //
14
+ // The stored value is NOT bare JSON: it is the timestamp on its own line, then
15
+ // the snapshot JSON. That is what lets the orphan sweep read one number per entry
16
+ // without parsing hundreds of kilobytes of payload on the boot path, and it keeps
17
+ // one source of truth per field. This module writes and reads that format and
18
+ // nothing else touches it; an unrecognised value is treated as expired, which is
19
+ // the same handling an entry from an older release would get anyway.
20
+ //
21
+ // localStorage rather than IndexedDB, deliberately. It is SYNCHRONOUS, which is
22
+ // exactly what the seam needs — hydration has to complete before the resume
23
+ // announces what the client holds — so a consumer using this needs no async
24
+ // bootstrap at all, and the kernel's boot path is unchanged. It also holds no
25
+ // connection open, so it costs nothing in bfcache eligibility, which is the
26
+ // reason the library declined to own a database in the first place. The cost is
27
+ // the origin quota (~5 MB in Safari), which is why this implementation sweeps and
28
+ // caps rather than only writing.
29
+
30
+ import type { ScrollbackPersistence } from "./types.js";
31
+ import type { StoreSnapshot } from "@cplieger/web-terminal-engine";
32
+ import { DEFAULT_MAX_AGE_MS } from "./scrollback.js";
33
+ import { positiveIntOption } from "./options.js";
34
+
35
+ /** Key prefix, so a snapshot cannot collide with the host application's own
36
+ * localStorage keys (a consumer that already persists UI state has some). */
37
+ const DEFAULT_PREFIX = "wt.scrollback.";
38
+
39
+ /**
40
+ * Total budget for everything this store owns, in CHARACTERS.
41
+ *
42
+ * A count cap was the first answer and it was wrong, because it does not bound
43
+ * what actually runs out. Measured on the real stored envelope at the 200-line
44
+ * default: a plain 80-column session is ~24 K characters, a coloured one (a
45
+ * prompt, `ls`, any TUI — several runs per line) ~60 K, and a wide coloured one
46
+ * ~112 K. Both WebKit and Blink account localStorage in UTF-16 code units, so the
47
+ * quota cost is roughly double the character count, and the floor to design
48
+ * against is Safari's ~5 MiB per origin (Chrome and Firefox are commonly ~10 MB).
49
+ *
50
+ * 512 KiB of characters is therefore about 1 MiB of quota, a fifth of that floor,
51
+ * which leaves the host application the rest — and holds roughly eight typical
52
+ * coloured sessions or four wide ones. The earlier 20-entry cap allowed 20 x 112 K
53
+ * = 2.2 M characters, over 4 MiB of quota, so the cap itself guaranteed the
54
+ * refusal it was meant to prevent.
55
+ */
56
+ const DEFAULT_MAX_BYTES = 512 * 1024;
57
+
58
+ export interface LocalScrollbackStorageOptions {
59
+ /** Key prefix (default `"wt.scrollback."`). */
60
+ prefix?: string;
61
+ /** Maximum age of a stored entry. Applied BOTH by this store's sweep and,
62
+ * because it is passed straight through on the returned object, by the kernel
63
+ * when it decides whether to load one — so the two cannot disagree. */
64
+ maxAgeMs?: number;
65
+ /** Total budget for everything this store owns, in CHARACTERS (default 512 KiB,
66
+ * about 1 MiB of localStorage quota since it is accounted in UTF-16). Enforced
67
+ * by the construction-time sweep AND after every write, oldest entry first. */
68
+ maxBytes?: number;
69
+ /** Newest lines persisted per session; passed through to the kernel. */
70
+ lines?: number;
71
+ /** Background save cadence; passed through to the kernel. */
72
+ saveIntervalMs?: number;
73
+ }
74
+
75
+ /** Read localStorage, or null when it is unavailable.
76
+ *
77
+ * Not a one-time capability check, and the two failures are not the same one: a
78
+ * browser set to block site data throws on ACCESS to `window.localStorage`
79
+ * (Chrome and Firefox), while Safari historically let access succeed in private
80
+ * browsing and threw on the WRITE instead. Storage can also be revoked
81
+ * mid-session (a user clearing site data, an ITP eviction), so every operation
82
+ * asks again and a refusal on the READ side degrades to "nothing stored" rather
83
+ * than to an exception crossing back into the kernel. */
84
+ function store(): Storage | null {
85
+ try {
86
+ return window.localStorage;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /** The `savedAt` of a stored value, without parsing the payload.
93
+ *
94
+ * The sweep needs one number from each entry and the payloads are hundreds of
95
+ * kilobytes, so parsing every one of them would put tens of milliseconds on the
96
+ * boot path to produce a timestamp comparison. Hence the stored format below:
97
+ * the timestamp on its own line, then the snapshot JSON. Reading it is a scan to
98
+ * the first newline whatever the payload's size or shape.
99
+ *
100
+ * An unreadable head reads as expired, which is the safe direction: an entry
101
+ * this module cannot recognise is one the kernel would reject anyway. */
102
+ function savedAtOf(value: string): number | null {
103
+ const nl = value.indexOf("\n");
104
+ if (nl <= 0) {
105
+ return null;
106
+ }
107
+ const at = Number(value.slice(0, nl));
108
+ return Number.isFinite(at) ? at : null;
109
+ }
110
+
111
+ /**
112
+ * A `ScrollbackPersistence` backed by `localStorage`, complete with the orphan
113
+ * collection the kernel cannot do for itself.
114
+ *
115
+ * The kernel enforces the age bound on the entries it LOADS, which collects a
116
+ * session the user comes back to. It can do nothing about a session nobody ever
117
+ * opens again — and the case this feature exists for, a browser discarding a tab,
118
+ * is precisely the case where no close handler ran to drop it. Only the store can
119
+ * enumerate, so only the store can sweep, and it does so once at construction:
120
+ * anything past the age bound goes, then the oldest go until the count fits.
121
+ *
122
+ * Pass it straight through:
123
+ *
124
+ * ```ts
125
+ * createTerminal("#terminal", {
126
+ * features: presetTabbed,
127
+ * persistScrollback: localScrollbackStorage(),
128
+ * });
129
+ * ```
130
+ */
131
+ export function localScrollbackStorage(
132
+ options: LocalScrollbackStorageOptions = {},
133
+ ): ScrollbackPersistence {
134
+ const prefix = options.prefix ?? DEFAULT_PREFIX;
135
+ // Resolved once and used for BOTH the sweep below and the kernel's load-time
136
+ // check, by handing the same number back on the returned object.
137
+ // Through the shared validator, so an invalid value is REPORTED here exactly as
138
+ // it is on the keeper's own options. These two inline ternaries used to swallow
139
+ // it, which made the same typo loud or silent depending only on which of the two
140
+ // option bags the consumer happened to write it in.
141
+ const maxAgeMs = positiveIntOption(
142
+ options.maxAgeMs,
143
+ DEFAULT_MAX_AGE_MS,
144
+ "localScrollbackStorage.maxAgeMs",
145
+ );
146
+ const maxBytes = positiveIntOption(
147
+ options.maxBytes,
148
+ DEFAULT_MAX_BYTES,
149
+ "localScrollbackStorage.maxBytes",
150
+ );
151
+
152
+ const keyFor = (sessionId: string): string => prefix + sessionId;
153
+
154
+ function remove(key: string): void {
155
+ const ls = store();
156
+ if (!ls) {
157
+ return;
158
+ }
159
+ try {
160
+ ls.removeItem(key);
161
+ } catch {
162
+ /* storage refused the write; nothing useful to do about it */
163
+ }
164
+ }
165
+
166
+ /** Every key this store owns, with its timestamp and its size in characters.
167
+ * Collected by index rather than by `Object.keys`, because localStorage's own
168
+ * keys are the only authoritative list and an entry written by an older release
169
+ * may not parse. */
170
+ function ownEntries(): { key: string; savedAt: number | null; chars: number }[] {
171
+ const ls = store();
172
+ if (!ls) {
173
+ return [];
174
+ }
175
+ const found: { key: string; savedAt: number | null; chars: number }[] = [];
176
+ try {
177
+ for (let i = 0; i < ls.length; i++) {
178
+ const key = ls.key(i);
179
+ if (!key?.startsWith(prefix)) {
180
+ continue;
181
+ }
182
+ const value = ls.getItem(key);
183
+ found.push({
184
+ key,
185
+ savedAt: value === null ? null : savedAtOf(value),
186
+ chars: (value?.length ?? 0) + key.length,
187
+ });
188
+ }
189
+ } catch {
190
+ return found;
191
+ }
192
+ return found;
193
+ }
194
+
195
+ /** Bring the store within its budgets: expired entries go first (they are dead
196
+ * weight whatever the total), then the oldest survivors until the character
197
+ * budget fits.
198
+ *
199
+ * Run at construction AND after every successful write, because a sweep that
200
+ * only runs at construction bounds nothing on a long-lived page: a session
201
+ * opened after boot is a new key nothing has audited, and the case this store
202
+ * exists for is precisely the page that stays open for days. */
203
+ function sweep(): void {
204
+ const now = Date.now();
205
+ const live: { key: string; savedAt: number; chars: number }[] = [];
206
+ let total = 0;
207
+ for (const entry of ownEntries()) {
208
+ // An unreadable timestamp is treated as expired: the kernel would refuse
209
+ // the entry anyway, so keeping it only consumes quota.
210
+ if (entry.savedAt === null || Math.abs(now - entry.savedAt) > maxAgeMs) {
211
+ remove(entry.key);
212
+ continue;
213
+ }
214
+ live.push({ key: entry.key, savedAt: entry.savedAt, chars: entry.chars });
215
+ total += entry.chars;
216
+ }
217
+ if (total <= maxBytes) {
218
+ return;
219
+ }
220
+ // Newest first, then keep entries while they fit and drop the rest: an entry
221
+ // the user last saw a minute ago is worth more than one from this morning.
222
+ // Written as "keep what fits" rather than "remove until it fits" because the
223
+ // latter, walking a newest-first list, evicts the newest — which is what the
224
+ // first version did.
225
+ live.sort((a, b) => b.savedAt - a.savedAt);
226
+ let kept = 0;
227
+ for (const entry of live) {
228
+ if (kept + entry.chars <= maxBytes) {
229
+ kept += entry.chars;
230
+ continue;
231
+ }
232
+ remove(entry.key);
233
+ }
234
+ }
235
+
236
+ sweep();
237
+
238
+ return {
239
+ maxAgeMs,
240
+ ...(options.lines !== undefined ? { lines: options.lines } : {}),
241
+ ...(options.saveIntervalMs !== undefined ? { saveIntervalMs: options.saveIntervalMs } : {}),
242
+ load(sessionId) {
243
+ const ls = store();
244
+ if (!ls) {
245
+ return null;
246
+ }
247
+ let raw: string | null;
248
+ try {
249
+ raw = ls.getItem(keyFor(sessionId));
250
+ } catch {
251
+ return null;
252
+ }
253
+ if (raw === null) {
254
+ return null;
255
+ }
256
+ const savedAt = savedAtOf(raw);
257
+ if (savedAt === null) {
258
+ remove(keyFor(sessionId));
259
+ return null;
260
+ }
261
+ try {
262
+ // The snapshot is returned unvalidated on purpose: the kernel validates
263
+ // every field against the engine's snapshot contract, and duplicating that
264
+ // here would give two places to disagree about what a usable entry is. A
265
+ // parse failure is the one thing this layer must handle, since it throws.
266
+ return { savedAt, snapshot: JSON.parse(raw.slice(raw.indexOf("\n") + 1)) as StoreSnapshot };
267
+ } catch {
268
+ remove(keyFor(sessionId));
269
+ return null;
270
+ }
271
+ },
272
+ save(sessionId, entry) {
273
+ const ls = store();
274
+ if (!ls) {
275
+ // Storage is unavailable, not merely full. Throwing lets the keeper see
276
+ // that nothing was persisted; returning normally made it record a
277
+ // watermark for a write that never happened, after which its background
278
+ // pass skipped the session until output advanced again.
279
+ throw new Error("web-terminal-ui: localStorage is unavailable");
280
+ }
281
+ // The timestamp on its own line, then the snapshot: one source of truth per
282
+ // field, and the sweep above reads the head without parsing the payload.
283
+ const key = keyFor(sessionId);
284
+ const value = `${String(entry.savedAt)}\n${JSON.stringify(entry.snapshot)}`;
285
+ if (value.length + key.length > maxBytes) {
286
+ // One entry larger than the whole budget cannot be stored without evicting
287
+ // everything, and would still not fit. Refuse it and KEEP whatever is
288
+ // already there: a smaller, older snapshot of this session restores
289
+ // something, where an eviction spiral restores nothing.
290
+ throw new Error("web-terminal-ui: scrollback snapshot exceeds the storage budget");
291
+ }
292
+ try {
293
+ ls.setItem(key, value);
294
+ } catch (err) {
295
+ // Over quota, or storage revoked mid-session. Deliberately does NOT delete
296
+ // the previous value: a refused replacement does not invalidate what is
297
+ // already stored, and deleting it turned a transient quota failure into
298
+ // permanent loss of this session's history. Sweep (an expired neighbour may
299
+ // be all that is in the way), retry once, and rethrow if it still will not
300
+ // land, so the keeper does not record a phantom save.
301
+ sweep();
302
+ try {
303
+ ls.setItem(key, value);
304
+ } catch {
305
+ throw err instanceof Error ? err : new Error(String(err));
306
+ }
307
+ return;
308
+ }
309
+ // Enforce the budget against what is now stored, including this write.
310
+ sweep();
311
+ },
312
+ drop(sessionId) {
313
+ remove(keyFor(sessionId));
314
+ },
315
+ };
316
+ }
@@ -0,0 +1,333 @@
1
+ // Scrollback persistence: the kernel's half of CreateTerminalOptions.
2
+ // persistScrollback.
3
+ //
4
+ // The split is mechanism here, storage in the consumer. The engine supplies the
5
+ // data (LineStore.snapshot / LineStore.fromSnapshot, plain structuredClone-safe
6
+ // objects) and the identity of the server process it belongs to
7
+ // (connection.serverEpochOf / adoptPersistedEpoch); this module owns WHEN a
8
+ // snapshot is taken, WHETHER a stored one may be trusted, and the age bound. The
9
+ // consumer owns where the bytes live, because a library that opened its own
10
+ // IndexedDB would hold a connection that costs bfcache eligibility — which this
11
+ // kernel depends on — and because how long a terminal's output may sit on disk
12
+ // is an application decision.
13
+ //
14
+ // It serves both compositions through one object: the kernel hydrates its
15
+ // implicit store for a single unmanaged terminal, and a session-owning feature
16
+ // gets a hydrated store per session from ctx.newLineStore(sessionId). Anything
17
+ // tracked here is saved; nothing else is.
18
+
19
+ import { LineStore, connection } from "@cplieger/web-terminal-engine";
20
+ import { positiveIntOption } from "./options.js";
21
+ import type { PersistedScrollback, ScrollbackPersistence } from "./types.js";
22
+
23
+ /**
24
+ * Newest lines persisted per session when the consumer names no bound.
25
+ *
26
+ * 200, not the store's own cap. The closest precedent is VS Code's
27
+ * `terminal.integrated.persistentSessionScrollback`, which restores 100 lines by
28
+ * default — evidence that the screen plus recent history is what a returning user
29
+ * needs, and that a full buffer is not. 200 gives headroom over that while keeping
30
+ * the cost honest: measured on the real stored envelope, 200 lines is ~24 K
31
+ * characters plain and ~60 K coloured, serialising in under a millisecond, against
32
+ * ~122 K / ~298 K and up to 4 ms at 1000. Since a write happens on every
33
+ * backgrounding and on a timer while output advances, that difference is paid
34
+ * repeatedly on the device this feature exists for.
35
+ */
36
+ const DEFAULT_PERSIST_LINES = 200;
37
+ /** How long a stored entry may be used. A week covers "back to it on Monday"
38
+ * while keeping an abandoned session's output from living indefinitely.
39
+ *
40
+ * Exported within the kernel so a storage implementation that must SWEEP its own
41
+ * orphans (scrollback-storage.ts) bounds them by the same number the keeper
42
+ * refuses to load past. Two constants here would drift into a store that keeps
43
+ * entries nothing will ever read, or deletes ones still in use. */
44
+ export const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
45
+ /** Background save cadence while content is advancing. */
46
+ const DEFAULT_SAVE_INTERVAL_MS = 10_000;
47
+
48
+ /** What the kernel drives. Every method is safe to call at any point in the
49
+ * terminal's life, including after stop(). */
50
+ export interface ScrollbackKeeper {
51
+ /** The store for a session: hydrated from storage when a usable entry exists,
52
+ * otherwise a fresh one. Either way the store is tracked for saving. Seeds
53
+ * the persisted server epoch, so this MUST be called before that session
54
+ * connects. */
55
+ storeFor(sessionId: string): LineStore;
56
+ /** Track a store this module did not create (the kernel's implicit store when
57
+ * nothing was restored for it), so it is saved like any other. It reads as
58
+ * needing a save, whatever it already holds. */
59
+ track(sessionId: string, store: LineStore): void;
60
+ /** Stop tracking a session and delete its stored entry (a closed tab). */
61
+ forget(sessionId: string): void;
62
+ /** Save every tracked session whose content has advanced since its last
63
+ * recorded save. For the page-lifecycle callbacks, which are the last chance
64
+ * rather than an optimisation. */
65
+ flush(): void;
66
+ /** Report that a session withheld its id, so persistence could not apply. */
67
+ noteMissingSessionId(): void;
68
+ /** Release the background timer. */
69
+ stop(): void;
70
+ }
71
+
72
+ /** Read a stored entry's timestamp defensively. The value has been outside this
73
+ * program's memory, so its type is an assumption until checked. */
74
+ function savedAtOf(entry: unknown): number | null {
75
+ if (typeof entry !== "object" || entry === null) {
76
+ return null;
77
+ }
78
+ const at = (entry as Record<string, unknown>)["savedAt"];
79
+ return typeof at === "number" && Number.isFinite(at) ? at : null;
80
+ }
81
+
82
+ /** Read a stored snapshot's server epoch defensively, or 0 for "unknown".
83
+ *
84
+ * Stricter than "a finite number", because this value is adopted as a session's
85
+ * IDENTITY: the server reports a process-start timestamp in nanoseconds, so a
86
+ * negative or fractional value cannot have come from one and is a corrupt or
87
+ * hand-edited entry wearing the shape of a real one. 0 is the library's existing
88
+ * "unknown", and every rejection lands there. */
89
+ function epochOf(entry: unknown): number {
90
+ if (typeof entry !== "object" || entry === null) {
91
+ return 0;
92
+ }
93
+ const snap = (entry as Record<string, unknown>)["snapshot"];
94
+ if (typeof snap !== "object" || snap === null) {
95
+ return 0;
96
+ }
97
+ const epoch = (snap as Record<string, unknown>)["serverEpoch"];
98
+ return typeof epoch === "number" && Number.isSafeInteger(epoch) && epoch > 0 ? epoch : 0;
99
+ }
100
+
101
+ export function createScrollbackKeeper(
102
+ cfg: ScrollbackPersistence,
103
+ storeCap: number | undefined,
104
+ ): ScrollbackKeeper {
105
+ const lines = positiveIntOption(cfg.lines, DEFAULT_PERSIST_LINES, "persistScrollback.lines");
106
+ const maxAgeMs = positiveIntOption(
107
+ cfg.maxAgeMs,
108
+ DEFAULT_MAX_AGE_MS,
109
+ "persistScrollback.maxAgeMs",
110
+ );
111
+ const saveIntervalMs = positiveIntOption(
112
+ cfg.saveIntervalMs,
113
+ DEFAULT_SAVE_INTERVAL_MS,
114
+ "persistScrollback.saveIntervalMs",
115
+ );
116
+
117
+ /** Every store that should be saved, by session id. */
118
+ const tracked = new Map<string, LineStore>();
119
+ /** The highest absolute index each session's last save contained, so the
120
+ * background pass can skip a session whose content has not advanced. Absent
121
+ * means never saved, which always reads as needing a save. */
122
+ const savedThrough = new Map<string, number>();
123
+ let warnedMissingId = false;
124
+ let warnedNoEpoch = false;
125
+ let warnedSaveFailed = false;
126
+ let stopped = false;
127
+
128
+ function newStore(): LineStore {
129
+ return new LineStore(storeCap);
130
+ }
131
+
132
+ function drop(sessionId: string): void {
133
+ try {
134
+ cfg.drop(sessionId);
135
+ } catch {
136
+ /* storage unavailable, or the consumer threw: nothing to do about it */
137
+ }
138
+ }
139
+
140
+ /** Load and validate a stored entry, dropping it when it cannot be used.
141
+ * Returns the hydrated store, or null to start empty and take a full resume.
142
+ *
143
+ * Every rejection below has that same correct handling, which is why none of
144
+ * them throws or reports: the worst outcome of starting empty is one slow
145
+ * restore, and the worst outcome of hydrating something unsound is a terminal
146
+ * that shows content from a session that no longer exists. */
147
+ function hydrate(sessionId: string): LineStore | null {
148
+ let entry: PersistedScrollback | null | undefined;
149
+ try {
150
+ entry = cfg.load(sessionId);
151
+ } catch {
152
+ return null;
153
+ }
154
+ if (entry === null || entry === undefined) {
155
+ return null;
156
+ }
157
+
158
+ const savedAt = savedAtOf(entry);
159
+ // Distance in either direction: a clock that moved backwards would otherwise
160
+ // leave an entry whose age never reaches the bound.
161
+ if (savedAt === null || Math.abs(Date.now() - savedAt) > maxAgeMs) {
162
+ drop(sessionId);
163
+ return null;
164
+ }
165
+
166
+ const store = LineStore.fromSnapshot(entry.snapshot, storeCap);
167
+ if (store === null) {
168
+ drop(sessionId);
169
+ return null;
170
+ }
171
+
172
+ // Structure checked; now the one rejection that is a real decision rather
173
+ // than a validation. An entry with no server epoch cannot be verified
174
+ // against the server it is about to be shown next to, and absolute line
175
+ // indices only mean anything within one server process: a restarted server
176
+ // begins again near 0, so a hydrated store whose epoch cannot be compared
177
+ // would present old content as live AND then refuse the new session's
178
+ // output, because those low indices fall below what the hydrated store
179
+ // believes it evicted. A terminal that fills in slowly is a far better
180
+ // failure than one that is wrong and then permanently blank, so an
181
+ // unverifiable entry is discarded. In practice this never fires: an epoch is
182
+ // missing only from a server that reports none at all, in which case restart
183
+ // detection was never available to anyone.
184
+ const epoch = epochOf(entry);
185
+ if (epoch === 0) {
186
+ if (!warnedNoEpoch) {
187
+ warnedNoEpoch = true;
188
+ console.warn(
189
+ "web-terminal-ui: discarding persisted scrollback with no server epoch (this server does not report one, so a restart cannot be detected)",
190
+ );
191
+ }
192
+ drop(sessionId);
193
+ return null;
194
+ }
195
+
196
+ // The epoch must reach the connection layer BEFORE this session's resume, or
197
+ // the first resumeAck records it with nothing to compare against and a
198
+ // restart goes undetected for exactly the content that needed checking.
199
+ connection.adoptPersistedEpoch(sessionId, epoch);
200
+ return store;
201
+ }
202
+
203
+ function saveOne(sessionId: string, store: LineStore): void {
204
+ // Save under the epoch this session is CURRENTLY known to belong to. A
205
+ // session that has not resumed yet reports 0, and a snapshot that cannot be
206
+ // verified on the way back in is not worth writing on the way out.
207
+ const epoch = connection.serverEpochOf(sessionId);
208
+ if (epoch === 0) {
209
+ return;
210
+ }
211
+ const snapshot = store.snapshot(epoch, lines);
212
+ // null means the store holds nothing. Skipping rather than writing an empty
213
+ // entry is the engine's contract, and it matters here: it is what stops a
214
+ // just-reset store from erasing a good snapshot. The stale entry that
215
+ // survives instead is self-invalidating, because its epoch no longer
216
+ // matches — which is the whole reason the epoch is persisted.
217
+ if (snapshot === null) {
218
+ return;
219
+ }
220
+ try {
221
+ cfg.save(sessionId, { savedAt: Date.now(), snapshot });
222
+ } catch {
223
+ // Nothing was persisted (over quota, storage revoked, an entry too large).
224
+ // Do NOT record a watermark: the whole point of the watermark is "this is on
225
+ // disk", and recording one here made the background pass skip the session
226
+ // until its output advanced again, which is how a full store stopped
227
+ // persisting silently. Warned once, because a feature that quietly does
228
+ // nothing is worse than one that says why.
229
+ if (!warnedSaveFailed) {
230
+ warnedSaveFailed = true;
231
+ console.warn(
232
+ "web-terminal-ui: could not persist scrollback (storage full or unavailable); the terminal is unaffected and will keep retrying",
233
+ );
234
+ }
235
+ return;
236
+ }
237
+ savedThrough.set(sessionId, snapshot.highest);
238
+ }
239
+
240
+ /** Save a session whose content has advanced since its last recorded save.
241
+ *
242
+ * Absent from `savedThrough` means never saved, which always counts as
243
+ * advanced. */
244
+ function saveIfAdvanced(sessionId: string, store: LineStore): void {
245
+ if (store.highestIndex() !== savedThrough.get(sessionId)) {
246
+ saveOne(sessionId, store);
247
+ }
248
+ }
249
+
250
+ /** The background pass.
251
+ *
252
+ * "Advanced" is the highest absolute index, so it misses a redraw that rewrote
253
+ * rows already on screen without printing new ones. That is a deliberate trade,
254
+ * not a free one, and the residue is worth naming: usually the re-sent window
255
+ * corrects a stale row on the next resume, but not when the session kept
256
+ * printing while the page was gone — the window has moved past that row by then,
257
+ * and the replay only covers lines above `haveThrough`, so a stale in-place
258
+ * rewrite (a spinner, a progress line) can sit in restored scrollback until it is
259
+ * evicted. Bounded by one screen height, and it needs the discard path where no
260
+ * lifecycle callback ran. Closing it would mean re-serialising the tail on every
261
+ * screen update, a continuous cost on the device least able to afford it, to fix
262
+ * at most a screenful of cosmetically stale history. */
263
+ function saveAdvanced(): void {
264
+ for (const [sessionId, store] of tracked) {
265
+ saveIfAdvanced(sessionId, store);
266
+ }
267
+ }
268
+
269
+ const timer = setInterval(saveAdvanced, saveIntervalMs);
270
+
271
+ return {
272
+ storeFor(sessionId) {
273
+ const restored = hydrate(sessionId);
274
+ const store = restored ?? newStore();
275
+ tracked.set(sessionId, store);
276
+ if (restored !== null) {
277
+ // A restored store's content IS the entry that was just read, so seed the
278
+ // watermark to stop the first background pass from rewriting a
279
+ // byte-identical entry. Nothing else may seed it: an unsaved store must
280
+ // read as needing a save, whatever it already holds.
281
+ savedThrough.set(sessionId, restored.highestIndex());
282
+ }
283
+ return store;
284
+ },
285
+ track(sessionId, store) {
286
+ tracked.set(sessionId, store);
287
+ savedThrough.delete(sessionId);
288
+ },
289
+ forget(sessionId) {
290
+ tracked.delete(sessionId);
291
+ savedThrough.delete(sessionId);
292
+ drop(sessionId);
293
+ },
294
+ flush() {
295
+ if (stopped) {
296
+ return;
297
+ }
298
+ // Same predicate as the background pass, and NOT an unconditional write.
299
+ // Under the tabs feature every tab gets a store and every one is hydrated at
300
+ // load, but only the ACTIVE session's store advances, because there is one
301
+ // socket. So a page holding another session's store frozen at its load-time
302
+ // content would, on being backgrounded, write that frozen content over a
303
+ // second page's newer entry — with a fresh timestamp, which also made the
304
+ // stale copy the one the sweep preferred. The next discard then restored the
305
+ // rolled-back entry and replayed thousands of lines, which is the exact
306
+ // symptom this feature exists to remove.
307
+ //
308
+ // A store that has not advanced since its last recorded save has nothing new
309
+ // to write, so skipping it loses nothing. What it gives up is refreshing
310
+ // `savedAt` on a long-idle open session, costing one full replay after the
311
+ // age bound expires — the right trade against silently discarding another
312
+ // page's history.
313
+ for (const [sessionId, store] of tracked) {
314
+ saveIfAdvanced(sessionId, store);
315
+ }
316
+ },
317
+ noteMissingSessionId() {
318
+ if (warnedMissingId) {
319
+ return;
320
+ }
321
+ warnedMissingId = true;
322
+ console.warn(
323
+ "web-terminal-ui: persistScrollback is enabled but a feature called ctx.newLineStore() without a session id, so that session's scrollback is not persisted",
324
+ );
325
+ },
326
+ stop() {
327
+ stopped = true;
328
+ clearInterval(timer);
329
+ tracked.clear();
330
+ savedThrough.clear();
331
+ },
332
+ };
333
+ }