@spfn/core 0.3.0-beta.27 → 0.3.0-beta.29
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/codegen/index.d.ts +65 -2
- package/dist/codegen/index.js +160 -15
- package/dist/codegen/index.js.map +1 -1
- package/dist/contract/index.d.ts +2 -2
- package/dist/db/index.d.ts +39 -11
- package/dist/db/index.js +34 -14
- package/dist/db/index.js.map +1 -1
- package/dist/{error-TsUIjp_x.d.ts → error-CIZlYJlL.d.ts} +1 -1
- package/dist/event/index.d.ts +3 -3
- package/dist/event/index.js +10 -0
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +2 -2
- package/dist/event/sse/client.js +27 -17
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +3 -3
- package/dist/event/sse/index.js +115 -34
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +2 -2
- package/dist/event/ws/index.d.ts +3 -3
- package/dist/ops/index.d.ts +3 -3
- package/dist/ops/index.js +15 -5
- package/dist/ops/index.js.map +1 -1
- package/dist/route/index.d.ts +2 -2
- package/dist/route/index.js +15 -5
- package/dist/route/index.js.map +1 -1
- package/dist/{router-4YoIy5fN.d.ts → router-C57y-vz1.d.ts} +12 -0
- package/dist/{router-D5B0cpVN.d.ts → router-CX5uVjmU.d.ts} +29 -1
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +130 -39
- package/dist/server/index.js.map +1 -1
- package/dist/state/client.d.ts +73 -11
- package/dist/state/client.js +231 -31
- package/dist/state/client.js.map +1 -1
- package/dist/state/index.js +19 -4
- package/dist/state/index.js.map +1 -1
- package/dist/state/react.d.ts +18 -3
- package/dist/state/react.js +11 -5
- package/dist/state/react.js.map +1 -1
- package/dist/surface/index.d.ts +3 -3
- package/dist/surface/index.js +15 -5
- package/dist/surface/index.js.map +1 -1
- package/dist/{types-04gu3uMj.d.ts → types-CurMab2Q.d.ts} +19 -2
- package/dist/{types-CqTsDETV.d.ts → types-DncqXfWN.d.ts} +1 -1
- package/package.json +1 -1
- package/src/codegen/README.md +89 -2
- package/src/db/transaction/README.md +58 -35
- package/src/event/README.md +61 -14
- package/src/state/README.md +228 -35
package/dist/state/client.d.ts
CHANGED
|
@@ -51,9 +51,10 @@ interface StateFrame<V = unknown, D = unknown> {
|
|
|
51
51
|
data?: D;
|
|
52
52
|
}
|
|
53
53
|
/**
|
|
54
|
-
* What the client holds for one `(id, key)`: the
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* What the client holds for one `(id, key)`: the frame that last changed
|
|
55
|
+
* `status`, `value` or `data` — its `at` is that read's, not the newest `at`
|
|
56
|
+
* the client has seen — and whether the server still serves it. A state the
|
|
57
|
+
* server stopped serving before any value arrived is `{ status: 'revoked' }` alone.
|
|
57
58
|
*/
|
|
58
59
|
type StateEntry<V = unknown, D = unknown> = Readonly<StateFrame<V, D> & {
|
|
59
60
|
status: 'ready' | 'revoked';
|
|
@@ -65,7 +66,36 @@ type StateEntry<V = unknown, D = unknown> = Readonly<StateFrame<V, D> & {
|
|
|
65
66
|
}>;
|
|
66
67
|
/** Called after the entry of a subscribed `(id, key)` changed. */
|
|
67
68
|
type StateListener = () => void;
|
|
68
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Whether the client's stream is live. `'reconnecting'` until a stream opens and while the SSE
|
|
71
|
+
* client retries a dropped one — `attempt` is its count, `0` before its first retry;
|
|
72
|
+
* `'failed'` when the retries are exhausted (the SSE client's `maxReconnectAttempts`, the
|
|
73
|
+
* client's `maxRetries` for the first snapshot), until `retry()`, a new `subscribe` or `online`.
|
|
74
|
+
*/
|
|
75
|
+
type ConnectionStatus = {
|
|
76
|
+
status: 'connected';
|
|
77
|
+
} | {
|
|
78
|
+
status: 'reconnecting';
|
|
79
|
+
attempt: number;
|
|
80
|
+
} | {
|
|
81
|
+
status: 'failed';
|
|
82
|
+
};
|
|
83
|
+
/** Called with the new status after the client's connection status changed. */
|
|
84
|
+
type ConnectionListener = (status: ConnectionStatus) => void;
|
|
85
|
+
/**
|
|
86
|
+
* The snapshot a server render read, for a client to start from (`StateClientConfig.initial`).
|
|
87
|
+
* Pass what `api.getStates` answered: a snapshot whose manifest names a state the server does not
|
|
88
|
+
* serve keeps the stream from opening, as the SSE handler refuses that event. The router type
|
|
89
|
+
* parameter (`StateClientInitial<typeof states>`, `createStateClient<typeof states>`) is what types
|
|
90
|
+
* `snapshot`; without it the client is untyped, as before.
|
|
91
|
+
*/
|
|
92
|
+
interface StateClientInitial<TStates extends AnyStateRouter = any> {
|
|
93
|
+
/** What `api.getStates` (or the snapshot route) answered for the render. */
|
|
94
|
+
snapshot: StatesSnapshot<TStates['states']>;
|
|
95
|
+
/** The query that snapshot was read with; keyed entries are stored under its key. @default {} */
|
|
96
|
+
query?: Record<string, string>;
|
|
97
|
+
}
|
|
98
|
+
interface StateClientConfig<TStates extends AnyStateRouter = any> {
|
|
69
99
|
/**
|
|
70
100
|
* The SSE client the stream is opened on — `createAuthSSEClient()` for an
|
|
71
101
|
* authenticated app. Give the state client its own: an `SSEClient` holds
|
|
@@ -88,6 +118,21 @@ interface StateClientConfig {
|
|
|
88
118
|
* @default 3000
|
|
89
119
|
*/
|
|
90
120
|
retryDelay?: number;
|
|
121
|
+
/**
|
|
122
|
+
* How many times a failed first snapshot is read again before the client
|
|
123
|
+
* gives up: the status turns `'failed'` and no retry is set, until
|
|
124
|
+
* `retry()`, a new `subscribe` or `online`. The first read is not a retry.
|
|
125
|
+
* `0` retries until the snapshot succeeds.
|
|
126
|
+
* @default 0
|
|
127
|
+
*/
|
|
128
|
+
maxRetries?: number;
|
|
129
|
+
/**
|
|
130
|
+
* The snapshot the server rendered with. The client installs its manifest
|
|
131
|
+
* and applies its states at construction, so `current()` answers at once
|
|
132
|
+
* and the first `subscribe` opens the stream without reading the snapshot
|
|
133
|
+
* first. The stream's open still re-reads it.
|
|
134
|
+
*/
|
|
135
|
+
initial?: StateClientInitial<TStates>;
|
|
91
136
|
}
|
|
92
137
|
/**
|
|
93
138
|
* The browser side of a state router: one store per `(id, key)`, one SSE
|
|
@@ -99,8 +144,8 @@ interface StateClient<TStates extends AnyStateRouter> {
|
|
|
99
144
|
/** Read every state the query names a key for, and the manifest; each entry is applied and the snapshot returned. */
|
|
100
145
|
snapshot(query?: Record<string, string>): Promise<StatesSnapshot<TStates['states']>>;
|
|
101
146
|
/**
|
|
102
|
-
* Watch one `(id, key)`. The first subscriber reads the snapshot
|
|
103
|
-
* the stream. A key whose fields are not the state's goes to `onError` once
|
|
147
|
+
* Watch one `(id, key)`. The first subscriber reads the snapshot — unless
|
|
148
|
+
* the client was built with `initial` — then opens the stream. A key whose fields are not the state's goes to `onError` once
|
|
104
149
|
* the manifest is known. Returns the unsubscribe.
|
|
105
150
|
*/
|
|
106
151
|
subscribe<Id extends StateId<TStates>>(id: Id, ...args: [...key: StateKeyArgs<TStates, Id>, listener: StateListener]): () => void;
|
|
@@ -108,7 +153,22 @@ interface StateClient<TStates extends AnyStateRouter> {
|
|
|
108
153
|
write<Id extends WritableStateId<TStates>>(id: Id, input: StateCatalog<TStates>[Id]['input'], ...key: StateKeyArgs<TStates, Id>): Promise<StateFrameOf<TStates, Id>>;
|
|
109
154
|
/** The entry held for `(id, key)`, the same object until it changes. */
|
|
110
155
|
current<Id extends StateId<TStates>>(id: Id, ...key: StateKeyArgs<TStates, Id>): StateEntry<StateCatalog<TStates>[Id]['value'], StateCatalog<TStates>[Id]['data']> | undefined;
|
|
111
|
-
/**
|
|
156
|
+
/** The connection status, a frozen object: the same reference until it changes. */
|
|
157
|
+
connection(): ConnectionStatus;
|
|
158
|
+
/**
|
|
159
|
+
* Call `listener` with the new status on every change of `connection()`.
|
|
160
|
+
* Returns the unsubscribe (idempotent).
|
|
161
|
+
*/
|
|
162
|
+
subscribeConnection(listener: ConnectionListener): () => void;
|
|
163
|
+
/**
|
|
164
|
+
* Restart now when `'failed'` or `'reconnecting'`: the first snapshot is
|
|
165
|
+
* read again, its retry count reset, or the stream reopened. A no-op when
|
|
166
|
+
* `'connected'`, while the first snapshot is in flight, or with nothing
|
|
167
|
+
* subscribed — so after `close()`, until a new `subscribe`, which `retry()`
|
|
168
|
+
* then restarts as before. `online` is not listened to again after `close()`.
|
|
169
|
+
*/
|
|
170
|
+
retry(): void;
|
|
171
|
+
/** Close the stream, drop every listener and stop listening to `online`. */
|
|
112
172
|
close(): void;
|
|
113
173
|
}
|
|
114
174
|
|
|
@@ -135,10 +195,12 @@ interface StateClient<TStates extends AnyStateRouter> {
|
|
|
135
195
|
|
|
136
196
|
/**
|
|
137
197
|
* Build the client for `typeof states`. The first subscription (or call)
|
|
138
|
-
* reads the snapshot, whose manifest names every state
|
|
198
|
+
* reads the snapshot, whose manifest names every state — or takes it from
|
|
199
|
+
* `initial`, the snapshot the server rendered with; the stream then
|
|
139
200
|
* requests every state event — the server's `grant` can only activate an
|
|
140
|
-
* event the connection requested.
|
|
201
|
+
* event the connection requested. The router type parameter is what types
|
|
202
|
+
* `initial.snapshot`; without it the client is untyped, as before.
|
|
141
203
|
*/
|
|
142
|
-
declare function createStateClient<TStates extends AnyStateRouter>(config: StateClientConfig): StateClient<TStates>;
|
|
204
|
+
declare function createStateClient<TStates extends AnyStateRouter>(config: StateClientConfig<TStates>): StateClient<TStates>;
|
|
143
205
|
|
|
144
|
-
export { type AnyStateRouter, type InferStateData, type StateCatalog, type StateClient, type StateClientConfig, type StateEntry, type StateFrame, type StateFrameOf, type StateId, type StateKeyArgs, type StateListener, type WritableStateId, createStateClient };
|
|
206
|
+
export { type AnyStateRouter, type ConnectionListener, type ConnectionStatus, type InferStateData, type StateCatalog, type StateClient, type StateClientConfig, type StateClientInitial, type StateEntry, type StateFrame, type StateFrameOf, type StateId, type StateKeyArgs, type StateListener, type WritableStateId, createStateClient };
|
package/dist/state/client.js
CHANGED
|
@@ -20,14 +20,11 @@ function sameJson(a, b) {
|
|
|
20
20
|
const bRecord = b;
|
|
21
21
|
return aKeys.length === Object.keys(b).length && aKeys.every((field) => field in bRecord && sameJson(a[field], bRecord[field]));
|
|
22
22
|
}
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
return !sameJson(current.value, arrival.value) || !sameJson(current.data, arrival.data);
|
|
23
|
+
function isOlder(stamp, arrival) {
|
|
24
|
+
return stamp !== void 0 && arrival.at < stamp;
|
|
25
|
+
}
|
|
26
|
+
function changes(current, arrival) {
|
|
27
|
+
return current?.at === void 0 || !sameJson(current.value, arrival.value) || !sameJson(current.data, arrival.data);
|
|
31
28
|
}
|
|
32
29
|
function entryOf(frame, status) {
|
|
33
30
|
const { at, value, data } = frame;
|
|
@@ -35,6 +32,12 @@ function entryOf(frame, status) {
|
|
|
35
32
|
}
|
|
36
33
|
var StateStore = class {
|
|
37
34
|
entries = /* @__PURE__ */ new Map();
|
|
35
|
+
/**
|
|
36
|
+
* The newest `at` seen per slot, kept apart from the entry: an arrival
|
|
37
|
+
* that changes nothing still orders the ones after it, while the entry's
|
|
38
|
+
* `at` stays the `at` of the read that last changed the value.
|
|
39
|
+
*/
|
|
40
|
+
stamps = /* @__PURE__ */ new Map();
|
|
38
41
|
listeners = /* @__PURE__ */ new Map();
|
|
39
42
|
/**
|
|
40
43
|
* The slots the server stopped serving, with the generation of the
|
|
@@ -52,12 +55,16 @@ var StateStore = class {
|
|
|
52
55
|
}
|
|
53
56
|
/**
|
|
54
57
|
* Apply an arrival: the value under the ordering rule, the status from the
|
|
55
|
-
* revoked mark. Listeners hear only
|
|
58
|
+
* revoked mark. Listeners hear only a changed status, value or data.
|
|
56
59
|
*/
|
|
57
60
|
apply(slot, frame) {
|
|
58
61
|
const current = this.entries.get(slot);
|
|
59
62
|
const status = this.revoked.has(slot) ? "revoked" : "ready";
|
|
60
|
-
|
|
63
|
+
const ordered = !isOlder(this.stamps.get(slot), frame);
|
|
64
|
+
if (ordered) {
|
|
65
|
+
this.stamps.set(slot, frame.at);
|
|
66
|
+
}
|
|
67
|
+
if (ordered && changes(current, frame)) {
|
|
61
68
|
this.replace(slot, entryOf(frame, status));
|
|
62
69
|
} else if (current.status !== status) {
|
|
63
70
|
this.replace(slot, entryOf(current, status));
|
|
@@ -113,9 +120,21 @@ function notify(slot, listener) {
|
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
// src/state/client.ts
|
|
123
|
+
var CONNECTED = Object.freeze({ status: "connected" });
|
|
124
|
+
var FAILED = Object.freeze({ status: "failed" });
|
|
125
|
+
var STARTING = Object.freeze({ status: "reconnecting", attempt: 0 });
|
|
126
|
+
function reconnecting(attempt) {
|
|
127
|
+
return attempt === 0 ? STARTING : Object.freeze({ status: "reconnecting", attempt });
|
|
128
|
+
}
|
|
129
|
+
function sameStatus(a, b) {
|
|
130
|
+
return a.status === b.status && a.attempt === b.attempt;
|
|
131
|
+
}
|
|
116
132
|
function routeName(method, id) {
|
|
117
133
|
return `${method}${id[0].toUpperCase()}${id.slice(1)}State`;
|
|
118
134
|
}
|
|
135
|
+
function stateEventName(id) {
|
|
136
|
+
return `state.${id}.changed`;
|
|
137
|
+
}
|
|
119
138
|
function carries(query, key) {
|
|
120
139
|
return Object.entries(key).every(([field, value]) => query[field] === value);
|
|
121
140
|
}
|
|
@@ -130,6 +149,12 @@ var BrowserStateClient = class {
|
|
|
130
149
|
this.config = config;
|
|
131
150
|
this.api = createApi(config.api);
|
|
132
151
|
this.reportError = config.onError ?? ((error) => console.error("[state] A refresh failed:", error));
|
|
152
|
+
if (config.initial) {
|
|
153
|
+
this.hydrate(config.initial);
|
|
154
|
+
}
|
|
155
|
+
if (typeof globalThis.addEventListener === "function") {
|
|
156
|
+
globalThis.addEventListener("online", this.onOnline);
|
|
157
|
+
}
|
|
133
158
|
}
|
|
134
159
|
store = new StateStore();
|
|
135
160
|
api;
|
|
@@ -139,6 +164,11 @@ var BrowserStateClient = class {
|
|
|
139
164
|
manifestRead;
|
|
140
165
|
stream;
|
|
141
166
|
syncQueued = false;
|
|
167
|
+
status = STARTING;
|
|
168
|
+
connectionListeners = /* @__PURE__ */ new Set();
|
|
169
|
+
onOnline = () => this.retry();
|
|
170
|
+
/** The retries of the first snapshot since its last start, against `maxRetries`. */
|
|
171
|
+
retries = 0;
|
|
142
172
|
/** The lifetime whose subscriptions wait on the first snapshot — its read in flight, or its retry timer set. */
|
|
143
173
|
waitingLifetime;
|
|
144
174
|
retryTimer;
|
|
@@ -191,13 +221,70 @@ var BrowserStateClient = class {
|
|
|
191
221
|
current(id, key) {
|
|
192
222
|
return this.store.current(this.slot(id, key));
|
|
193
223
|
}
|
|
224
|
+
connection() {
|
|
225
|
+
return this.status;
|
|
226
|
+
}
|
|
227
|
+
subscribeConnection(listener) {
|
|
228
|
+
const notify2 = (status) => listener(status);
|
|
229
|
+
this.connectionListeners.add(notify2);
|
|
230
|
+
return () => {
|
|
231
|
+
this.connectionListeners.delete(notify2);
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Restart what gave up or is still retrying, now: the first snapshot
|
|
236
|
+
* while the manifest is unknown, else the stream — a new `sse.subscribe`,
|
|
237
|
+
* whose attempt count starts at 0. Nothing to do when connected or with
|
|
238
|
+
* nothing watched — so after `close()`, until a new `subscribe`.
|
|
239
|
+
*/
|
|
240
|
+
retry() {
|
|
241
|
+
if (this.status === CONNECTED || this.watched.size === 0) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (this.manifest) {
|
|
245
|
+
this.openStream(this.streamQuery());
|
|
246
|
+
} else {
|
|
247
|
+
this.rereadManifest();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
194
250
|
close() {
|
|
195
251
|
this.lifetime++;
|
|
196
252
|
clearTimeout(this.retryTimer);
|
|
197
253
|
this.retryTimer = void 0;
|
|
254
|
+
this.retries = 0;
|
|
198
255
|
this.closeStream();
|
|
199
256
|
this.watched.clear();
|
|
200
257
|
this.store.clearListeners();
|
|
258
|
+
this.connectionListeners.clear();
|
|
259
|
+
this.status = STARTING;
|
|
260
|
+
if (typeof globalThis.removeEventListener === "function") {
|
|
261
|
+
globalThis.removeEventListener("online", this.onOnline);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** The status is `next` from now; each connection listener hears it, a throwing one logged. */
|
|
265
|
+
setConnection(next) {
|
|
266
|
+
if (sameStatus(next, this.status)) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
this.status = next;
|
|
270
|
+
for (const listener of [...this.connectionListeners]) {
|
|
271
|
+
try {
|
|
272
|
+
listener(next);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.warn("[state] A connection listener threw:", error);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Start from the snapshot the server rendered with: its manifest, then
|
|
280
|
+
* each state present, as a read snapshot applies them, under generation 0
|
|
281
|
+
* — no connection yet. Nothing is marked `'revoked'`: nothing is watched,
|
|
282
|
+
* and an omission in the render's snapshot is not a denial by the stream.
|
|
283
|
+
*/
|
|
284
|
+
hydrate(initial) {
|
|
285
|
+
const snapshot = initial.snapshot;
|
|
286
|
+
this.installManifest(snapshot.manifest);
|
|
287
|
+
this.applyPresent(snapshot, initial.query ?? {}, this.tag());
|
|
201
288
|
}
|
|
202
289
|
slot(id, key) {
|
|
203
290
|
return StateStore.slot(id, stableKey(key));
|
|
@@ -282,16 +369,23 @@ var BrowserStateClient = class {
|
|
|
282
369
|
* generation alone — a frame or a write answer of the new connection may
|
|
283
370
|
* land first with a newer `at` — and the ordering rule decides only the value.
|
|
284
371
|
*/
|
|
285
|
-
serve(slot, frame,
|
|
286
|
-
if ((this.store.revokedBy(slot) ?? Infinity) <
|
|
372
|
+
serve(slot, frame, generation) {
|
|
373
|
+
if ((this.store.revokedBy(slot) ?? Infinity) < generation) {
|
|
287
374
|
this.store.allow(slot);
|
|
288
375
|
}
|
|
289
376
|
this.store.apply(slot, frame);
|
|
290
377
|
}
|
|
291
|
-
/**
|
|
378
|
+
/**
|
|
379
|
+
* A state present is served, under the generation rule of `serve`. A state
|
|
380
|
+
* the manifest does not describe, or a keyed one whose key fields the query
|
|
381
|
+
* lacks, is skipped: the server never serves either, but a hand-built
|
|
382
|
+
* `initial` might.
|
|
383
|
+
*/
|
|
292
384
|
applyPresent(snapshot, query, tag) {
|
|
293
385
|
for (const [id, result] of Object.entries(snapshot.states)) {
|
|
294
|
-
|
|
386
|
+
if (this.manifest[id]?.key.every((field) => field in query)) {
|
|
387
|
+
this.serve(this.slot(id, this.keyIn(id, query)), { at: snapshot.at, ...result }, tag.generation);
|
|
388
|
+
}
|
|
295
389
|
}
|
|
296
390
|
}
|
|
297
391
|
/**
|
|
@@ -318,7 +412,9 @@ var BrowserStateClient = class {
|
|
|
318
412
|
} else {
|
|
319
413
|
this.watched.delete(slot);
|
|
320
414
|
}
|
|
321
|
-
this.
|
|
415
|
+
if (delta === 1 || this.watched.size === 0 || this.status !== FAILED) {
|
|
416
|
+
this.queueSync();
|
|
417
|
+
}
|
|
322
418
|
}
|
|
323
419
|
/**
|
|
324
420
|
* One stream for the client, its query the union of the subscribed keys.
|
|
@@ -337,10 +433,15 @@ var BrowserStateClient = class {
|
|
|
337
433
|
/**
|
|
338
434
|
* Until the manifest is known, subscriptions wait for the first snapshot;
|
|
339
435
|
* the stream opens after it, never before — its `events=` comes from it.
|
|
436
|
+
* A client built with `initial` knows it from the start. With nothing
|
|
437
|
+
* watched the stream closes, the retry count starts over for the next
|
|
438
|
+
* wait, and the status is the start value: no stream, nothing answered.
|
|
340
439
|
*/
|
|
341
440
|
sync() {
|
|
342
441
|
if (this.watched.size === 0) {
|
|
442
|
+
this.retries = 0;
|
|
343
443
|
this.closeStream();
|
|
444
|
+
this.setConnection(STARTING);
|
|
344
445
|
return;
|
|
345
446
|
}
|
|
346
447
|
if (!this.manifest) {
|
|
@@ -348,7 +449,7 @@ var BrowserStateClient = class {
|
|
|
348
449
|
return;
|
|
349
450
|
}
|
|
350
451
|
const query = this.streamQuery();
|
|
351
|
-
if (this.stream?.text !== stableKey(query)) {
|
|
452
|
+
if (this.stream?.failed || this.stream?.text !== stableKey(query)) {
|
|
352
453
|
this.openStream(query);
|
|
353
454
|
}
|
|
354
455
|
}
|
|
@@ -356,13 +457,18 @@ var BrowserStateClient = class {
|
|
|
356
457
|
* One waiter per lifetime on the first snapshot. A failure goes to
|
|
357
458
|
* `onError`, and the read is retried after `retryDelay` — the stream that
|
|
358
459
|
* would reconnect on its own does not exist yet. A retry stops on
|
|
359
|
-
* `close()
|
|
460
|
+
* `close()`, when nothing is watched (`sync` closes instead), or after
|
|
461
|
+
* `maxRetries`; a wait started after that last one restarts the count.
|
|
360
462
|
*/
|
|
361
463
|
awaitManifest() {
|
|
362
464
|
const lifetime = this.lifetime;
|
|
363
465
|
if (this.waitingLifetime === lifetime) {
|
|
364
466
|
return;
|
|
365
467
|
}
|
|
468
|
+
if (this.status === FAILED) {
|
|
469
|
+
this.retries = 0;
|
|
470
|
+
this.setConnection(STARTING);
|
|
471
|
+
}
|
|
366
472
|
this.waitingLifetime = lifetime;
|
|
367
473
|
this.ensureManifest().then(
|
|
368
474
|
() => lifetime === this.lifetime && this.resync(),
|
|
@@ -379,14 +485,40 @@ var BrowserStateClient = class {
|
|
|
379
485
|
this.waitingLifetime = void 0;
|
|
380
486
|
this.sync();
|
|
381
487
|
}
|
|
382
|
-
/**
|
|
488
|
+
/**
|
|
489
|
+
* Read the first snapshot again after `retryDelay`, as the SSE client
|
|
490
|
+
* reconnects a dropped stream — or, with `maxRetries` spent, give up:
|
|
491
|
+
* `'failed'`, no timer, no wait.
|
|
492
|
+
*/
|
|
383
493
|
retryLater() {
|
|
494
|
+
const maxRetries = this.config.maxRetries ?? 0;
|
|
495
|
+
if (maxRetries > 0 && this.retries >= maxRetries) {
|
|
496
|
+
this.waitingLifetime = void 0;
|
|
497
|
+
this.setConnection(FAILED);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
this.retries++;
|
|
384
501
|
this.retryTimer = setTimeout(() => {
|
|
385
502
|
this.retryTimer = void 0;
|
|
386
503
|
this.resync();
|
|
387
504
|
}, this.config.retryDelay ?? 3e3);
|
|
388
505
|
this.retryTimer.unref?.();
|
|
389
506
|
}
|
|
507
|
+
/**
|
|
508
|
+
* `retry()` before the manifest: the count starts over and the first
|
|
509
|
+
* snapshot is read now, its pending timer cleared. A read in flight is
|
|
510
|
+
* already the retry; it is not doubled.
|
|
511
|
+
*/
|
|
512
|
+
rereadManifest() {
|
|
513
|
+
this.retries = 0;
|
|
514
|
+
if (this.waitingLifetime === this.lifetime && this.retryTimer === void 0) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
clearTimeout(this.retryTimer);
|
|
518
|
+
this.retryTimer = void 0;
|
|
519
|
+
this.setConnection(STARTING);
|
|
520
|
+
this.resync();
|
|
521
|
+
}
|
|
390
522
|
/**
|
|
391
523
|
* Every subscribed key's fields; one connection carries one value per
|
|
392
524
|
* field, so on a conflict the latest subscribed key wins. The keys it
|
|
@@ -399,33 +531,96 @@ var BrowserStateClient = class {
|
|
|
399
531
|
* Open (or reopen) the stream. `events` is every state of the manifest, in
|
|
400
532
|
* its order, never only the subscribed ones: a state subscribed later would
|
|
401
533
|
* otherwise be unrequested, and the server's `grant` cannot activate it.
|
|
534
|
+
* Unless connected, the status is `'reconnecting'` 0 until it opens: a
|
|
535
|
+
* new subscription counts its attempts from 0. A reopen on a key change
|
|
536
|
+
* while connected stays `'connected'`.
|
|
402
537
|
*/
|
|
403
538
|
openStream(query) {
|
|
404
539
|
this.closeStream();
|
|
540
|
+
if (this.status !== CONNECTED) {
|
|
541
|
+
this.setConnection(STARTING);
|
|
542
|
+
}
|
|
405
543
|
const ids = Object.keys(this.manifest);
|
|
406
|
-
const stream = { query, text: stableKey(query), generation: 0, close: () => void 0 };
|
|
544
|
+
const stream = { query, text: stableKey(query), generation: 0, closedByClient: false, failed: false, close: () => void 0 };
|
|
407
545
|
stream.close = this.config.sse.subscribe({
|
|
408
|
-
events: ids.map(
|
|
409
|
-
handlers: Object.fromEntries(ids.map((id) => [
|
|
546
|
+
events: ids.map(stateEventName),
|
|
547
|
+
handlers: Object.fromEntries(ids.map((id) => [stateEventName(id), (frame) => this.onFrame(id, frame, stream)])),
|
|
410
548
|
query,
|
|
411
|
-
// Every open — the first, a reconnect, a reopen on a key change — re-reads the
|
|
412
|
-
// snapshot. A frame sent while no stream was open is lost, whether its key
|
|
413
|
-
// changed or not; the snapshot read after the open covers that gap.
|
|
414
549
|
onOpen: () => {
|
|
415
550
|
stream.generation = ++this.generation;
|
|
416
|
-
this.
|
|
551
|
+
this.streamStatus(stream, CONNECTED);
|
|
417
552
|
},
|
|
553
|
+
onReconnect: (attempt) => this.streamStatus(stream, reconnecting(attempt)),
|
|
554
|
+
onClose: () => this.onStreamClose(stream),
|
|
555
|
+
onConnected: (connected) => this.onConnected(connected, stream),
|
|
418
556
|
onSubscriptionChange: (change) => this.onSubscriptionChange(change, stream)
|
|
419
557
|
});
|
|
420
558
|
this.stream = stream;
|
|
421
559
|
}
|
|
560
|
+
/** Flagged first: the SSE client fires `onClose` on this teardown too, and it is no failure. */
|
|
422
561
|
closeStream() {
|
|
423
|
-
this.stream
|
|
424
|
-
|
|
562
|
+
if (this.stream) {
|
|
563
|
+
this.stream.closedByClient = true;
|
|
564
|
+
this.stream.close();
|
|
565
|
+
this.stream = void 0;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/** The status of a callback of `stream`, when no other stream replaced it. */
|
|
569
|
+
streamStatus(stream, status) {
|
|
570
|
+
if (stream === this.stream) {
|
|
571
|
+
this.setConnection(status);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* The SSE client fires `onClose` on every teardown; one the client did
|
|
576
|
+
* not start is the reconnect attempts exhausted: `'failed'`, and the
|
|
577
|
+
* stream counts as absent for the next `sync`.
|
|
578
|
+
*/
|
|
579
|
+
onStreamClose(stream) {
|
|
580
|
+
if (!stream.closedByClient && stream === this.stream) {
|
|
581
|
+
stream.failed = true;
|
|
582
|
+
this.setConnection(FAILED);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Every connection — the first, a reconnect, a reopen on a key change —
|
|
587
|
+
* must cover the frames sent while no stream was open. The server says in
|
|
588
|
+
* `connected` which states it served their current frame right after it;
|
|
589
|
+
* the snapshot is read only when one it did not serve is neither denied
|
|
590
|
+
* nor served — or when the server predates `initial`. A denied state the
|
|
591
|
+
* client watches is `'revoked'`, as an omission from that snapshot would
|
|
592
|
+
* be. A connection already replaced is ignored.
|
|
593
|
+
*/
|
|
594
|
+
onConnected(connected, stream) {
|
|
595
|
+
if (stream !== this.stream || stream.generation !== this.generation) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const denied = new Set(connected.deniedEvents);
|
|
599
|
+
const served = new Set(connected.initial ?? []);
|
|
600
|
+
const unserved = Object.keys(this.manifest).map(stateEventName).filter((name) => !denied.has(name) && !served.has(name));
|
|
601
|
+
this.revokeDenied(denied, stream);
|
|
602
|
+
if (connected.initial && unserved.length === 0) {
|
|
603
|
+
this.readShadowedKeys(stream.query);
|
|
604
|
+
} else {
|
|
605
|
+
this.refresh(stream.query);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/** Each watched state the connection denies, on the key it carries, marked `'revoked'` by it. */
|
|
609
|
+
revokeDenied(denied, stream) {
|
|
610
|
+
for (const id of Object.keys(this.manifest)) {
|
|
611
|
+
const slot = this.slot(id, this.keyIn(id, stream.query));
|
|
612
|
+
if (denied.has(stateEventName(id)) && this.watched.has(slot)) {
|
|
613
|
+
this.store.revoke(slot, stream.generation);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
425
616
|
}
|
|
426
617
|
/** The snapshot for the stream's query, and one read for each subscribed key the query shadows. */
|
|
427
618
|
refresh(query) {
|
|
428
619
|
this.readSnapshot(query, this.tag()).catch(this.reportError);
|
|
620
|
+
this.readShadowedKeys(query);
|
|
621
|
+
}
|
|
622
|
+
/** One read for each subscribed key the query shadows: the stream serves one key per field. */
|
|
623
|
+
readShadowedKeys(query) {
|
|
429
624
|
for (const watch of this.watched.values()) {
|
|
430
625
|
if (watch.key && !carries(query, watch.key)) {
|
|
431
626
|
this.readShadowed(watch.id, watch.key, this.tag()).catch(this.reportError);
|
|
@@ -438,13 +633,18 @@ var BrowserStateClient = class {
|
|
|
438
633
|
*/
|
|
439
634
|
async readShadowed(id, key, tag) {
|
|
440
635
|
const frame = await this.api[routeName("get", id)].call({ query: key });
|
|
441
|
-
this.serve(this.slot(id, key), frame, tag);
|
|
636
|
+
this.serve(this.slot(id, key), frame, tag.generation);
|
|
442
637
|
}
|
|
443
|
-
/**
|
|
444
|
-
|
|
638
|
+
/**
|
|
639
|
+
* A frame for a key nobody here subscribed is dropped — the server's filter
|
|
640
|
+
* is the first guard, this the second. A frame is served under its
|
|
641
|
+
* connection's generation: the server sends one only on a connection that
|
|
642
|
+
* allows the state, so a newer connection's frame lifts an older revoke.
|
|
643
|
+
*/
|
|
644
|
+
onFrame(id, frame, stream) {
|
|
445
645
|
const slot = this.slot(id, frame.key);
|
|
446
646
|
if (this.watched.has(slot)) {
|
|
447
|
-
this.
|
|
647
|
+
this.serve(slot, frame, stream.generation);
|
|
448
648
|
}
|
|
449
649
|
}
|
|
450
650
|
/**
|