statewire 0.17.2 → 0.18.1
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/dist/client/connection.d.ts +2 -3
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +61 -25
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts +6 -2
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +158 -40
- package/dist/client/session.js.map +1 -1
- package/dist/client/storageError.d.ts +6 -0
- package/dist/client/storageError.d.ts.map +1 -0
- package/dist/client/storageError.js +12 -0
- package/dist/client/storageError.js.map +1 -0
- package/dist/statewire.d.ts +23 -11
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js.map +1 -1
- package/dist/storage.d.ts +33 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +371 -0
- package/dist/storage.js.map +1 -0
- package/dist/transports/sse.d.ts +2 -0
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +3 -0
- package/dist/transports/sse.js.map +1 -1
- package/package.json +5 -1
- package/src/client/connection.ts +57 -28
- package/src/client/session.ts +232 -72
- package/src/client/storageError.ts +9 -0
- package/src/statewire.ts +25 -20
- package/src/storage.ts +601 -0
- package/src/transports/sse.ts +5 -0
package/src/storage.ts
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { wrapStorageError } from "./client/storageError";
|
|
3
|
+
import { resource } from "@assistant-ui/tap";
|
|
4
|
+
import type { StatewireClient } from "./statewire";
|
|
5
|
+
import type { StatewireProtocol } from "./wire/protocol";
|
|
6
|
+
import { negotiationHeaders, protocolToken } from "./wire/protocol";
|
|
7
|
+
import { applyDocumentOps } from "./wire/documents";
|
|
8
|
+
|
|
9
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
10
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
|
+
|
|
12
|
+
const isOptionalString = (value: unknown): boolean =>
|
|
13
|
+
value === undefined || typeof value === "string";
|
|
14
|
+
|
|
15
|
+
const isPositiveSafeInteger = (value: unknown): value is number =>
|
|
16
|
+
typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
17
|
+
|
|
18
|
+
const isSessionCommand = (
|
|
19
|
+
value: unknown,
|
|
20
|
+
): value is StatewireClient.SessionCommand =>
|
|
21
|
+
isRecord(value) &&
|
|
22
|
+
isPositiveSafeInteger(value["seq"]) &&
|
|
23
|
+
isProtocol(value["protocol"]) &&
|
|
24
|
+
isProducer(value["produced"]) &&
|
|
25
|
+
typeof value["method"] === "string" &&
|
|
26
|
+
Array.isArray(value["params"]);
|
|
27
|
+
|
|
28
|
+
const isSessionVerdict = (
|
|
29
|
+
value: unknown,
|
|
30
|
+
): value is StatewireClient.SessionVerdict =>
|
|
31
|
+
isRecord(value) &&
|
|
32
|
+
isPositiveSafeInteger(value["seq"]) &&
|
|
33
|
+
(value["type"] === "result" ||
|
|
34
|
+
value["type"] === "rejected" ||
|
|
35
|
+
value["type"] === "failed" ||
|
|
36
|
+
value["type"] === "crashed" ||
|
|
37
|
+
value["type"] === "expired") &&
|
|
38
|
+
isOptionalString(value["code"]) &&
|
|
39
|
+
isOptionalString(value["message"]);
|
|
40
|
+
|
|
41
|
+
const isSendFailure = (
|
|
42
|
+
value: unknown,
|
|
43
|
+
): value is StatewireClient.SendFailure => {
|
|
44
|
+
if (!isRecord(value)) return false;
|
|
45
|
+
switch (value["fate"]) {
|
|
46
|
+
case "rejected":
|
|
47
|
+
return (
|
|
48
|
+
value["cause"] === "server" &&
|
|
49
|
+
isOptionalString(value["code"]) &&
|
|
50
|
+
isOptionalString(value["message"])
|
|
51
|
+
);
|
|
52
|
+
case "failed":
|
|
53
|
+
return value["cause"] === "server" && isOptionalString(value["message"]);
|
|
54
|
+
case "crashed":
|
|
55
|
+
case "expired":
|
|
56
|
+
return value["cause"] === "server";
|
|
57
|
+
case "aborted":
|
|
58
|
+
return (
|
|
59
|
+
(value["cause"] === "unmount" ||
|
|
60
|
+
value["cause"] === "stream-gone" ||
|
|
61
|
+
value["cause"] === "session-lost") &&
|
|
62
|
+
(value["predecessorLost"] === undefined ||
|
|
63
|
+
value["predecessorLost"] === true)
|
|
64
|
+
);
|
|
65
|
+
case "lost":
|
|
66
|
+
return (
|
|
67
|
+
value["cause"] === "unmount" ||
|
|
68
|
+
value["cause"] === "stream-gone" ||
|
|
69
|
+
value["cause"] === "session-lost"
|
|
70
|
+
);
|
|
71
|
+
default:
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const isFailedCommand = (
|
|
77
|
+
value: unknown,
|
|
78
|
+
): value is StatewireClient.FailedCommand =>
|
|
79
|
+
isRecord(value) &&
|
|
80
|
+
isProtocol(value["protocol"]) &&
|
|
81
|
+
isProducer(value["produced"]) &&
|
|
82
|
+
typeof value["command"] === "string" &&
|
|
83
|
+
Array.isArray(value["params"]) &&
|
|
84
|
+
isSendFailure(value["failure"]) &&
|
|
85
|
+
typeof value["ts"] === "number" &&
|
|
86
|
+
Number.isFinite(value["ts"]) &&
|
|
87
|
+
isPositiveSafeInteger(value["seq"]);
|
|
88
|
+
|
|
89
|
+
const isSessionRecord = (
|
|
90
|
+
value: unknown,
|
|
91
|
+
): value is StatewireClient.SessionRecord =>
|
|
92
|
+
isRecord(value) &&
|
|
93
|
+
typeof value["clientId"] === "string" &&
|
|
94
|
+
value["clientId"].length > 0 &&
|
|
95
|
+
isContext(value["context"]) &&
|
|
96
|
+
isPositiveSafeInteger(value["nextSeq"]) &&
|
|
97
|
+
isPositiveSafeInteger(value["ctxNextSeq"]) &&
|
|
98
|
+
Array.isArray(value["commands"]) &&
|
|
99
|
+
value["commands"].every(isSessionCommand) &&
|
|
100
|
+
(value["verdicts"] === undefined ||
|
|
101
|
+
(Array.isArray(value["verdicts"]) &&
|
|
102
|
+
value["verdicts"].every(isSessionVerdict))) &&
|
|
103
|
+
(value["failed"] === undefined ||
|
|
104
|
+
(Array.isArray(value["failed"]) && value["failed"].every(isFailedCommand)));
|
|
105
|
+
|
|
106
|
+
const isProtocol = (value: unknown) =>
|
|
107
|
+
value === undefined ||
|
|
108
|
+
(typeof value === "string" && value !== "*" && protocolToken.test(value));
|
|
109
|
+
|
|
110
|
+
const isProducer = (value: unknown) => {
|
|
111
|
+
if (value === undefined) return true;
|
|
112
|
+
if (!isRecord(value) || !isRecord(value["wire"])) return false;
|
|
113
|
+
try {
|
|
114
|
+
negotiationHeaders({
|
|
115
|
+
wireVersion: value["wire"] as StatewireProtocol.Range,
|
|
116
|
+
applicationProtocols: value["protocols"] as
|
|
117
|
+
| readonly StatewireProtocol.Offer[]
|
|
118
|
+
| "*",
|
|
119
|
+
});
|
|
120
|
+
return value["protocols"] !== undefined;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const isContextOperation = (value: unknown) => {
|
|
127
|
+
if (!isRecord(value)) return false;
|
|
128
|
+
if (
|
|
129
|
+
value["doc"] !== undefined &&
|
|
130
|
+
(typeof value["doc"] !== "number" ||
|
|
131
|
+
!Number.isSafeInteger(value["doc"]) ||
|
|
132
|
+
value["doc"] < 0)
|
|
133
|
+
)
|
|
134
|
+
return false;
|
|
135
|
+
if (value["op"] === "mount") {
|
|
136
|
+
try {
|
|
137
|
+
applyDocumentOps(
|
|
138
|
+
{ mode: "mounted", documents: new Map() },
|
|
139
|
+
[value],
|
|
140
|
+
undefined,
|
|
141
|
+
true,
|
|
142
|
+
);
|
|
143
|
+
return true;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (value["op"] === "unmount") return true;
|
|
149
|
+
if (!["add", "replace", "remove"].includes(value["op"] as string))
|
|
150
|
+
return false;
|
|
151
|
+
return (
|
|
152
|
+
Array.isArray(value["path"]) &&
|
|
153
|
+
value["path"].every(
|
|
154
|
+
(part) =>
|
|
155
|
+
typeof part === "string" ||
|
|
156
|
+
(typeof part === "number" && Number.isSafeInteger(part) && part >= 0),
|
|
157
|
+
) &&
|
|
158
|
+
(value["op"] === "remove" || "value" in value)
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const isContext = (value: unknown) => {
|
|
163
|
+
if (value === undefined) return true;
|
|
164
|
+
if (
|
|
165
|
+
!isRecord(value) ||
|
|
166
|
+
!Array.isArray(value["ops"]) ||
|
|
167
|
+
!Array.isArray(value["pending"])
|
|
168
|
+
)
|
|
169
|
+
return false;
|
|
170
|
+
if (value["recover"] !== undefined && value["recover"] !== "version-1")
|
|
171
|
+
return false;
|
|
172
|
+
try {
|
|
173
|
+
const documents = applyDocumentOps(
|
|
174
|
+
{ mode: "mounted", documents: new Map() },
|
|
175
|
+
value["ops"],
|
|
176
|
+
undefined,
|
|
177
|
+
true,
|
|
178
|
+
);
|
|
179
|
+
for (const entry of value["pending"]) {
|
|
180
|
+
if (
|
|
181
|
+
!isRecord(entry) ||
|
|
182
|
+
!isPositiveSafeInteger(entry["seq"]) ||
|
|
183
|
+
!Array.isArray(entry["ops"])
|
|
184
|
+
)
|
|
185
|
+
return false;
|
|
186
|
+
if (!entry["ops"].every(isContextOperation)) return false;
|
|
187
|
+
}
|
|
188
|
+
return documents.mode === "mounted";
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const decode = (
|
|
195
|
+
raw: string | null,
|
|
196
|
+
legacyProtocol: KeyValueSessionStorage.Options["legacyProtocol"],
|
|
197
|
+
): StatewireClient.SessionRecord | null => {
|
|
198
|
+
if (raw === null) return null;
|
|
199
|
+
const parsed: unknown = JSON.parse(raw);
|
|
200
|
+
if (!isSessionRecord(parsed))
|
|
201
|
+
throw new Error("statewire: invalid stored session record");
|
|
202
|
+
const {
|
|
203
|
+
statewireStorageVersion: format,
|
|
204
|
+
savedAt: _savedAt,
|
|
205
|
+
...value
|
|
206
|
+
} = parsed as StatewireClient.SessionRecord & {
|
|
207
|
+
statewireStorageVersion?: unknown;
|
|
208
|
+
savedAt?: unknown;
|
|
209
|
+
};
|
|
210
|
+
if (!isSessionRecord(value))
|
|
211
|
+
throw new Error("statewire: invalid stored session record");
|
|
212
|
+
if (format !== undefined && format !== 1)
|
|
213
|
+
throw new Error("statewire: unsupported session storage version");
|
|
214
|
+
if (legacyProtocol === undefined || format !== undefined) return value;
|
|
215
|
+
const produced = {
|
|
216
|
+
wire: { version: "2026-09-07" },
|
|
217
|
+
protocols: [legacyProtocol],
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
...value,
|
|
221
|
+
...(value.context === undefined && value.ctxNextSeq > 1
|
|
222
|
+
? {
|
|
223
|
+
context: {
|
|
224
|
+
ops: [
|
|
225
|
+
{
|
|
226
|
+
op: "mount",
|
|
227
|
+
protocol: legacyProtocol.name,
|
|
228
|
+
main: true,
|
|
229
|
+
value: {},
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
pending: [],
|
|
233
|
+
recover: "version-1",
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
: {}),
|
|
237
|
+
commands: value.commands.map((command) =>
|
|
238
|
+
command.produced === undefined
|
|
239
|
+
? {
|
|
240
|
+
...command,
|
|
241
|
+
protocol: command.protocol ?? legacyProtocol.name,
|
|
242
|
+
produced,
|
|
243
|
+
}
|
|
244
|
+
: command,
|
|
245
|
+
),
|
|
246
|
+
...(value.failed === undefined
|
|
247
|
+
? {}
|
|
248
|
+
: {
|
|
249
|
+
failed: value.failed.map((entry) => ({
|
|
250
|
+
...entry,
|
|
251
|
+
protocol: entry.protocol ?? legacyProtocol.name,
|
|
252
|
+
produced: entry.produced ?? produced,
|
|
253
|
+
})),
|
|
254
|
+
}),
|
|
255
|
+
};
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const active = new WeakMap<
|
|
259
|
+
KeyValueSessionStorage.Backend,
|
|
260
|
+
Map<string, number>
|
|
261
|
+
>();
|
|
262
|
+
|
|
263
|
+
const sweep = async (
|
|
264
|
+
options: KeyValueSessionStorage.Options,
|
|
265
|
+
currentKey: string,
|
|
266
|
+
quota = false,
|
|
267
|
+
) => {
|
|
268
|
+
const { backend, retention, namespace = "" } = options;
|
|
269
|
+
if (!retention || !backend.getAllKeys) return;
|
|
270
|
+
const entries: { key: string; savedAt: number }[] = [];
|
|
271
|
+
const candidate = (raw: string | null) => {
|
|
272
|
+
if (raw === null) return undefined;
|
|
273
|
+
let value: unknown;
|
|
274
|
+
try {
|
|
275
|
+
value = JSON.parse(raw);
|
|
276
|
+
} catch {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
const format = isRecord(value)
|
|
280
|
+
? value["statewireStorageVersion"]
|
|
281
|
+
: undefined;
|
|
282
|
+
if (format !== undefined && format !== 1) return undefined;
|
|
283
|
+
if (
|
|
284
|
+
!isSessionRecord(value) ||
|
|
285
|
+
value.commands.length ||
|
|
286
|
+
value.failed?.length ||
|
|
287
|
+
value.context?.pending.length
|
|
288
|
+
)
|
|
289
|
+
return undefined;
|
|
290
|
+
const savedAt =
|
|
291
|
+
(value as Record<string, unknown>)["savedAt"] ??
|
|
292
|
+
(value as Record<string, unknown>)["updatedAt"];
|
|
293
|
+
return typeof savedAt === "number" && Number.isFinite(savedAt)
|
|
294
|
+
? savedAt
|
|
295
|
+
: undefined;
|
|
296
|
+
};
|
|
297
|
+
for (const key of await backend.getAllKeys()) {
|
|
298
|
+
if (
|
|
299
|
+
!key.startsWith(namespace) ||
|
|
300
|
+
key === currentKey ||
|
|
301
|
+
active.get(backend)?.has(key)
|
|
302
|
+
)
|
|
303
|
+
continue;
|
|
304
|
+
const savedAt = candidate(await backend.getItem(key));
|
|
305
|
+
if (savedAt !== undefined) entries.push({ key, savedAt });
|
|
306
|
+
}
|
|
307
|
+
entries.sort((a, b) => b.savedAt - a.savedAt);
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
let retained = 1;
|
|
310
|
+
const quotaStart = Math.floor(entries.length / 2);
|
|
311
|
+
for (const [index, entry] of entries.entries()) {
|
|
312
|
+
const expired =
|
|
313
|
+
retention.maxAgeMs !== undefined &&
|
|
314
|
+
now - entry.savedAt > retention.maxAgeMs;
|
|
315
|
+
const overflow =
|
|
316
|
+
retention.maxEntries !== undefined && retained >= retention.maxEntries;
|
|
317
|
+
if (expired || overflow || (quota && index >= quotaStart)) {
|
|
318
|
+
const remove = async () => {
|
|
319
|
+
if (active.get(backend)?.has(entry.key)) return;
|
|
320
|
+
if (candidate(await backend.getItem(entry.key)) !== entry.savedAt)
|
|
321
|
+
return;
|
|
322
|
+
if (active.get(backend)?.has(entry.key)) return;
|
|
323
|
+
await backend.removeItem(entry.key);
|
|
324
|
+
};
|
|
325
|
+
if (backend.withUnused) await backend.withUnused(entry.key, remove);
|
|
326
|
+
else await remove();
|
|
327
|
+
} else retained++;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const isQuotaError = (error: unknown) =>
|
|
332
|
+
error instanceof Error && error.name === "QuotaExceededError";
|
|
333
|
+
|
|
334
|
+
const useKeyValueSessionStorage = (
|
|
335
|
+
options: KeyValueSessionStorage.Options,
|
|
336
|
+
): StatewireClient.SessionStorage => {
|
|
337
|
+
const {
|
|
338
|
+
backend,
|
|
339
|
+
key,
|
|
340
|
+
namespace = "",
|
|
341
|
+
legacyProtocol: suppliedLegacyProtocol,
|
|
342
|
+
migrate,
|
|
343
|
+
retention: suppliedRetention,
|
|
344
|
+
onError,
|
|
345
|
+
} = options;
|
|
346
|
+
const latestErrorHandler = useRef(onError);
|
|
347
|
+
latestErrorHandler.current = onError;
|
|
348
|
+
const reportError = useMemo(
|
|
349
|
+
() => (error: unknown) => latestErrorHandler.current?.(error),
|
|
350
|
+
[],
|
|
351
|
+
);
|
|
352
|
+
const legacyName = suppliedLegacyProtocol?.name;
|
|
353
|
+
const legacyVersion = suppliedLegacyProtocol?.version;
|
|
354
|
+
const legacyProtocol = useMemo(
|
|
355
|
+
() =>
|
|
356
|
+
legacyName === undefined
|
|
357
|
+
? undefined
|
|
358
|
+
: { name: legacyName, version: legacyVersion! },
|
|
359
|
+
[legacyName, legacyVersion],
|
|
360
|
+
);
|
|
361
|
+
const retain = suppliedRetention !== undefined;
|
|
362
|
+
const maxAgeMs = suppliedRetention?.maxAgeMs;
|
|
363
|
+
const maxEntries = suppliedRetention?.maxEntries;
|
|
364
|
+
const retention = useMemo(
|
|
365
|
+
() =>
|
|
366
|
+
retain
|
|
367
|
+
? {
|
|
368
|
+
...(maxAgeMs === undefined ? {} : { maxAgeMs }),
|
|
369
|
+
...(maxEntries === undefined ? {} : { maxEntries }),
|
|
370
|
+
}
|
|
371
|
+
: undefined,
|
|
372
|
+
[retain, maxAgeMs, maxEntries],
|
|
373
|
+
);
|
|
374
|
+
const configured = useMemo(
|
|
375
|
+
() => ({
|
|
376
|
+
backend,
|
|
377
|
+
key,
|
|
378
|
+
namespace,
|
|
379
|
+
...(retention === undefined ? {} : { retention }),
|
|
380
|
+
}),
|
|
381
|
+
[backend, key, namespace, retention],
|
|
382
|
+
);
|
|
383
|
+
const storageKey = namespace + key;
|
|
384
|
+
if (legacyProtocol !== undefined)
|
|
385
|
+
negotiationHeaders({ applicationProtocols: [legacyProtocol] });
|
|
386
|
+
if (retention !== undefined) {
|
|
387
|
+
if (!namespace)
|
|
388
|
+
throw new Error("statewire: session retention requires a namespace");
|
|
389
|
+
if (!backend.getAllKeys)
|
|
390
|
+
throw new Error("statewire: session retention requires getAllKeys");
|
|
391
|
+
if (
|
|
392
|
+
retention.maxAgeMs !== undefined &&
|
|
393
|
+
(!Number.isFinite(retention.maxAgeMs) || retention.maxAgeMs < 0)
|
|
394
|
+
)
|
|
395
|
+
throw new Error("statewire: invalid session retention maxAgeMs");
|
|
396
|
+
if (
|
|
397
|
+
retention.maxEntries !== undefined &&
|
|
398
|
+
(!Number.isSafeInteger(retention.maxEntries) || retention.maxEntries < 1)
|
|
399
|
+
)
|
|
400
|
+
throw new Error("statewire: invalid session retention maxEntries");
|
|
401
|
+
}
|
|
402
|
+
const state = useMemo(() => {
|
|
403
|
+
let raw: string | null | Promise<string | null>;
|
|
404
|
+
try {
|
|
405
|
+
raw = backend.getItem(storageKey);
|
|
406
|
+
} catch (cause) {
|
|
407
|
+
const error = wrapStorageError(cause);
|
|
408
|
+
reportError(error);
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
const state = {
|
|
412
|
+
record: null as StatewireClient.SessionRecord | null,
|
|
413
|
+
ready: undefined as Promise<void> | undefined,
|
|
414
|
+
queue: Promise.resolve(),
|
|
415
|
+
pending: 0,
|
|
416
|
+
failure: undefined as unknown,
|
|
417
|
+
asynchronous: typeof raw !== "string" && raw !== null,
|
|
418
|
+
hydrated: typeof raw === "string" || raw === null,
|
|
419
|
+
};
|
|
420
|
+
if (state.asynchronous) {
|
|
421
|
+
state.ready = Promise.resolve(raw)
|
|
422
|
+
.then((value) => {
|
|
423
|
+
state.record = decode(value, legacyProtocol);
|
|
424
|
+
state.hydrated = true;
|
|
425
|
+
})
|
|
426
|
+
.catch((cause: unknown) => {
|
|
427
|
+
const error = wrapStorageError(cause);
|
|
428
|
+
if (state.failure !== error) reportError(error);
|
|
429
|
+
state.failure = error;
|
|
430
|
+
throw error;
|
|
431
|
+
});
|
|
432
|
+
state.ready.catch(() => {});
|
|
433
|
+
} else {
|
|
434
|
+
try {
|
|
435
|
+
state.record = decode(raw as string | null, legacyProtocol);
|
|
436
|
+
} catch (cause) {
|
|
437
|
+
const error = wrapStorageError(cause);
|
|
438
|
+
reportError(error);
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return state;
|
|
443
|
+
}, [backend, storageKey, legacyProtocol, reportError]);
|
|
444
|
+
|
|
445
|
+
useEffect(() => {
|
|
446
|
+
const entries = active.get(backend) ?? new Map<string, number>();
|
|
447
|
+
active.set(backend, entries);
|
|
448
|
+
entries.set(storageKey, (entries.get(storageKey) ?? 0) + 1);
|
|
449
|
+
return () => {
|
|
450
|
+
const count = entries.get(storageKey)! - 1;
|
|
451
|
+
if (count === 0) entries.delete(storageKey);
|
|
452
|
+
else entries.set(storageKey, count);
|
|
453
|
+
};
|
|
454
|
+
}, [backend, storageKey]);
|
|
455
|
+
|
|
456
|
+
useEffect(() => {
|
|
457
|
+
if (retention !== undefined)
|
|
458
|
+
void sweep(configured, storageKey).catch((cause: unknown) => {
|
|
459
|
+
const error = wrapStorageError(cause);
|
|
460
|
+
state.failure = error;
|
|
461
|
+
reportError(error);
|
|
462
|
+
});
|
|
463
|
+
}, [state, configured, storageKey, retention, reportError]);
|
|
464
|
+
|
|
465
|
+
return useMemo(
|
|
466
|
+
() => ({
|
|
467
|
+
...(state.ready === undefined ? {} : { ready: state.ready }),
|
|
468
|
+
load: () => {
|
|
469
|
+
if (state.failure !== undefined) throw state.failure;
|
|
470
|
+
if (!state.hydrated)
|
|
471
|
+
throw new Error(
|
|
472
|
+
"statewire: await session storage readiness before loading",
|
|
473
|
+
);
|
|
474
|
+
if (state.asynchronous || state.pending > 0) return state.record;
|
|
475
|
+
let raw: string | null | Promise<string | null>;
|
|
476
|
+
try {
|
|
477
|
+
raw = backend.getItem(storageKey);
|
|
478
|
+
} catch (cause) {
|
|
479
|
+
const error = wrapStorageError(cause);
|
|
480
|
+
reportError(error);
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
if (typeof raw !== "string" && raw !== null)
|
|
484
|
+
throw new Error(
|
|
485
|
+
"statewire: storage backend changed from synchronous to asynchronous",
|
|
486
|
+
);
|
|
487
|
+
try {
|
|
488
|
+
return decode(raw, legacyProtocol);
|
|
489
|
+
} catch (cause) {
|
|
490
|
+
const error = wrapStorageError(cause);
|
|
491
|
+
reportError(error);
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
save: (record: StatewireClient.SessionRecord) => {
|
|
496
|
+
if (state.failure !== undefined) throw state.failure;
|
|
497
|
+
if (!state.hydrated)
|
|
498
|
+
throw new Error(
|
|
499
|
+
"statewire: await session storage readiness before saving",
|
|
500
|
+
);
|
|
501
|
+
if (!isSessionRecord(record))
|
|
502
|
+
throw new Error("statewire: invalid session record");
|
|
503
|
+
const encoded = JSON.stringify({
|
|
504
|
+
...record,
|
|
505
|
+
statewireStorageVersion: 1,
|
|
506
|
+
savedAt: Date.now(),
|
|
507
|
+
});
|
|
508
|
+
state.record = record;
|
|
509
|
+
const recoverQuota = async (error: unknown) => {
|
|
510
|
+
if (!isQuotaError(error) || retention === undefined) throw error;
|
|
511
|
+
await sweep(configured, storageKey, true);
|
|
512
|
+
await backend.setItem(storageKey, encoded);
|
|
513
|
+
};
|
|
514
|
+
const write = async () => {
|
|
515
|
+
try {
|
|
516
|
+
await backend.setItem(storageKey, encoded);
|
|
517
|
+
} catch (error) {
|
|
518
|
+
await recoverQuota(error);
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
if (!state.asynchronous && state.pending === 0) {
|
|
522
|
+
let result: void | Promise<void>;
|
|
523
|
+
try {
|
|
524
|
+
result = backend.setItem(storageKey, encoded);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
if (!isQuotaError(error) || retention === undefined) {
|
|
527
|
+
state.failure = wrapStorageError(error);
|
|
528
|
+
reportError(state.failure);
|
|
529
|
+
throw state.failure;
|
|
530
|
+
}
|
|
531
|
+
result = recoverQuota(error);
|
|
532
|
+
}
|
|
533
|
+
if (result === undefined) return;
|
|
534
|
+
state.pending++;
|
|
535
|
+
state.queue = result;
|
|
536
|
+
} else {
|
|
537
|
+
state.pending++;
|
|
538
|
+
state.queue = state.queue.then(() => state.ready).then(write);
|
|
539
|
+
}
|
|
540
|
+
state.queue = state.queue
|
|
541
|
+
.catch((cause: unknown) => {
|
|
542
|
+
const error = wrapStorageError(cause);
|
|
543
|
+
if (state.failure !== error) reportError(error);
|
|
544
|
+
state.failure = error;
|
|
545
|
+
throw error;
|
|
546
|
+
})
|
|
547
|
+
.finally(() => {
|
|
548
|
+
state.pending--;
|
|
549
|
+
});
|
|
550
|
+
state.queue.catch(() => {});
|
|
551
|
+
},
|
|
552
|
+
flush: () => {
|
|
553
|
+
if (state.failure !== undefined) throw state.failure;
|
|
554
|
+
if (!state.asynchronous && state.pending === 0) return;
|
|
555
|
+
return (async () => {
|
|
556
|
+
await state.ready;
|
|
557
|
+
for (;;) {
|
|
558
|
+
const queue = state.queue;
|
|
559
|
+
await queue;
|
|
560
|
+
if (queue === state.queue) return;
|
|
561
|
+
}
|
|
562
|
+
})();
|
|
563
|
+
},
|
|
564
|
+
...(migrate === undefined ? {} : { migrate }),
|
|
565
|
+
}),
|
|
566
|
+
[
|
|
567
|
+
state,
|
|
568
|
+
backend,
|
|
569
|
+
storageKey,
|
|
570
|
+
legacyProtocol,
|
|
571
|
+
migrate,
|
|
572
|
+
retention,
|
|
573
|
+
configured,
|
|
574
|
+
reportError,
|
|
575
|
+
],
|
|
576
|
+
);
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
/** Persist sessions through a synchronous or asynchronous string storage backend. */
|
|
580
|
+
export const KeyValueSessionStorage = resource(useKeyValueSessionStorage);
|
|
581
|
+
|
|
582
|
+
export namespace KeyValueSessionStorage {
|
|
583
|
+
export type Backend = {
|
|
584
|
+
getItem: (key: string) => string | null | Promise<string | null>;
|
|
585
|
+
setItem: (key: string, value: string) => void | Promise<void>;
|
|
586
|
+
removeItem: (key: string) => void | Promise<void>;
|
|
587
|
+
getAllKeys?: () => readonly string[] | Promise<readonly string[]>;
|
|
588
|
+
/** Runs cleanup under exclusive access, skipping keys in use by another runtime. */
|
|
589
|
+
withUnused?: (key: string, action: () => Promise<void>) => Promise<void>;
|
|
590
|
+
};
|
|
591
|
+
export type Options = {
|
|
592
|
+
backend: Backend;
|
|
593
|
+
key: string;
|
|
594
|
+
namespace?: string;
|
|
595
|
+
/** The application format used by sessions saved before version negotiation. */
|
|
596
|
+
legacyProtocol?: { name: string; version: string };
|
|
597
|
+
migrate?: StatewireClient.SessionStorage["migrate"];
|
|
598
|
+
retention?: { maxAgeMs?: number; maxEntries?: number };
|
|
599
|
+
onError?: (error: unknown) => void;
|
|
600
|
+
};
|
|
601
|
+
}
|
package/src/transports/sse.ts
CHANGED
|
@@ -30,6 +30,7 @@ export type StatewireFrame = {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
export type StatewireRemoteProps = {
|
|
33
|
+
beforeAttach?: () => void | Promise<void>;
|
|
33
34
|
clientId: string;
|
|
34
35
|
/** Lanes to ride the attach frame (no-lease supersession only); records the included seqs. */
|
|
35
36
|
takeAttachLanes: () => StatewireFrame | null;
|
|
@@ -52,6 +53,7 @@ export type ConnectionState = StatewireClient.ConnectionState;
|
|
|
52
53
|
|
|
53
54
|
/** The live wire a remote session drives: connection status plus frame submission. */
|
|
54
55
|
export type RemoteLink = {
|
|
56
|
+
storageFailed: (error: Error) => void;
|
|
55
57
|
connection: StatewireClient.Connection;
|
|
56
58
|
/** A deferred first attach still waiting; `reconnect()` releases it. */
|
|
57
59
|
parked: boolean;
|
|
@@ -118,6 +120,9 @@ const attemptStream = async (
|
|
|
118
120
|
options: StatewireClient.HttpOptions,
|
|
119
121
|
abort: AbortController,
|
|
120
122
|
): Promise<AttemptOutcome> => {
|
|
123
|
+
const persistence = c.ctx.beforeAttach?.();
|
|
124
|
+
if (persistence) await persistence;
|
|
125
|
+
if (abort.signal.aborted) return "ended";
|
|
121
126
|
c.lease = null;
|
|
122
127
|
const headers = await resolveHeaders(options.headers, c.authStale);
|
|
123
128
|
for (const [name, value] of Object.entries(negotiationHeaders(options))) {
|