statewire 0.9.1 → 0.10.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.
- package/dist/client/connection.d.ts +5 -2
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +123 -23
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts +7 -4
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +62 -15
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +5 -14
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js +4 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/client/timing.d.ts +2 -0
- package/dist/client/timing.d.ts.map +1 -1
- package/dist/client/timing.js +8 -2
- package/dist/client/timing.js.map +1 -1
- package/dist/host/http.d.ts +7 -0
- package/dist/host/http.d.ts.map +1 -0
- package/dist/host/http.js +175 -0
- package/dist/host/http.js.map +1 -0
- package/dist/host/socket.d.ts +102 -1
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +48 -27
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +5 -0
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host.d.ts +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/statewire.d.ts +34 -8
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +9 -2
- package/dist/statewire.js.map +1 -1
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +6 -4
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +5 -9
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +212 -40
- package/src/client/session.ts +90 -24
- package/src/client/sessionStore.ts +8 -15
- package/src/client/timing.ts +10 -2
- package/src/host/http.ts +280 -0
- package/src/host/socket.ts +120 -57
- package/src/host-internal.ts +5 -0
- package/src/host.ts +1 -0
- package/src/statewire.ts +65 -9
- package/src/transports/sse.ts +6 -5
- package/src/transports/ws.ts +4 -9
- package/src/types.ts +1 -1
package/src/client/connection.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { flushTapSync } from "@assistant-ui/tap";
|
|
3
3
|
import { reviveOps, reviveValue } from "../wire/meta";
|
|
4
|
-
import { backoff } from "./timing";
|
|
4
|
+
import { backoff, timing } from "./timing";
|
|
5
5
|
import type { ConnectionState, StatewireRemoteProps } from "../transports/sse";
|
|
6
6
|
import type { StatewireClient } from "../statewire";
|
|
7
7
|
import type { StatewireHost } from "../host";
|
|
@@ -83,6 +83,8 @@ export type Conn = {
|
|
|
83
83
|
hasSnapshot: boolean;
|
|
84
84
|
lease: string | null;
|
|
85
85
|
attempt: number;
|
|
86
|
+
authDenials: number;
|
|
87
|
+
liveAt: number | null;
|
|
86
88
|
episode: Episode | null;
|
|
87
89
|
awaitingHello: boolean;
|
|
88
90
|
held: StatewireCmd[];
|
|
@@ -107,6 +109,108 @@ const protocolError = (message: string): never => {
|
|
|
107
109
|
throw new Error(`statewire: protocol error: ${message}`);
|
|
108
110
|
};
|
|
109
111
|
|
|
112
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
113
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
114
|
+
|
|
115
|
+
const isNonnegativeSafeInteger = (value: unknown): value is number =>
|
|
116
|
+
Number.isSafeInteger(value) && (value as number) >= 0;
|
|
117
|
+
|
|
118
|
+
const isPositiveSafeInteger = (value: unknown): value is number =>
|
|
119
|
+
Number.isSafeInteger(value) && (value as number) > 0;
|
|
120
|
+
|
|
121
|
+
const validateOptionalString = (value: unknown, field: string): void => {
|
|
122
|
+
if (value !== undefined && typeof value !== "string") {
|
|
123
|
+
protocolError(`${field} must be a string`);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const validateSynScalars = (syn: Record<string, unknown>) => {
|
|
128
|
+
if (syn["seq"] !== undefined && !isNonnegativeSafeInteger(syn["seq"])) {
|
|
129
|
+
protocolError("syn.seq must be a nonnegative safe integer");
|
|
130
|
+
}
|
|
131
|
+
if (syn["ctxSeq"] !== undefined && !isNonnegativeSafeInteger(syn["ctxSeq"])) {
|
|
132
|
+
protocolError("syn.ctxSeq must be a nonnegative safe integer");
|
|
133
|
+
}
|
|
134
|
+
if (syn["retry"] !== undefined && !isNonnegativeSafeInteger(syn["retry"])) {
|
|
135
|
+
protocolError("syn.retry must be a nonnegative safe integer");
|
|
136
|
+
}
|
|
137
|
+
validateOptionalString(syn["id"], "syn.id");
|
|
138
|
+
if (syn["idle"] !== undefined && typeof syn["idle"] !== "boolean") {
|
|
139
|
+
protocolError("syn.idle must be a boolean");
|
|
140
|
+
}
|
|
141
|
+
validateOptionalString(syn["record"], "syn.record");
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const validateRefusalSyn = (value: unknown): StatewireSyn => {
|
|
145
|
+
if (!isObject(value)) return protocolError("syn must be an object");
|
|
146
|
+
validateSynScalars(value);
|
|
147
|
+
if (
|
|
148
|
+
value["id"] !== undefined ||
|
|
149
|
+
value["idle"] !== undefined ||
|
|
150
|
+
value["record"] !== undefined
|
|
151
|
+
) {
|
|
152
|
+
protocolError("invalid refusal syn");
|
|
153
|
+
}
|
|
154
|
+
if (
|
|
155
|
+
value["seq"] === undefined &&
|
|
156
|
+
value["ctxSeq"] === undefined &&
|
|
157
|
+
value["retry"] === undefined
|
|
158
|
+
) {
|
|
159
|
+
protocolError("refusal syn has no watermarks or retry");
|
|
160
|
+
}
|
|
161
|
+
return value as StatewireSyn;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const validateCmd = (value: unknown): value is StatewireCmd => {
|
|
165
|
+
if (!isObject(value)) return protocolError("cmd entry must be an object");
|
|
166
|
+
if (!isPositiveSafeInteger(value["seq"])) {
|
|
167
|
+
protocolError("cmd.seq must be a positive safe integer");
|
|
168
|
+
}
|
|
169
|
+
validateOptionalString(value["type"], "cmd.type");
|
|
170
|
+
validateOptionalString(value["code"], "cmd.code");
|
|
171
|
+
validateOptionalString(value["message"], "cmd.message");
|
|
172
|
+
if (value["dur"] !== undefined && value["dur"] !== true) {
|
|
173
|
+
protocolError("cmd.dur must be true");
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const validateFin = (value: unknown): value is StatewireHost.Fin => {
|
|
179
|
+
if (!isObject(value)) return protocolError("fin must be an object");
|
|
180
|
+
if (typeof value["reason"] !== "string") {
|
|
181
|
+
protocolError("fin.reason must be a string");
|
|
182
|
+
}
|
|
183
|
+
validateOptionalString(value["message"], "fin.message");
|
|
184
|
+
return true;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const validateEnvelope = (value: unknown): StatewireEnvelope => {
|
|
188
|
+
if (!isObject(value)) return protocolError("packet must be an object");
|
|
189
|
+
if (value["ops"] !== undefined && !Array.isArray(value["ops"])) {
|
|
190
|
+
protocolError("ops must be an array");
|
|
191
|
+
}
|
|
192
|
+
if (value["cmd"] !== undefined) {
|
|
193
|
+
const cmd = value["cmd"];
|
|
194
|
+
if (!Array.isArray(cmd)) return protocolError("cmd must be an array");
|
|
195
|
+
for (const entry of cmd) validateCmd(entry);
|
|
196
|
+
}
|
|
197
|
+
if (value["idle"] !== undefined && typeof value["idle"] !== "boolean") {
|
|
198
|
+
protocolError("idle must be a boolean");
|
|
199
|
+
}
|
|
200
|
+
if (value["next"] !== undefined) {
|
|
201
|
+
if (value["next"] !== true) protocolError("next must be true");
|
|
202
|
+
if (
|
|
203
|
+
value["cmd"] === undefined ||
|
|
204
|
+
value["ops"] !== undefined ||
|
|
205
|
+
value["fin"] !== undefined
|
|
206
|
+
) {
|
|
207
|
+
protocolError("next must ride a cmd-only packet");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (value["fin"] !== undefined) validateFin(value["fin"]);
|
|
211
|
+
return value as StatewireEnvelope;
|
|
212
|
+
};
|
|
213
|
+
|
|
110
214
|
// A hold's release delivers, never settles: fin and bare close both drain it.
|
|
111
215
|
const releaseHeld = (c: Conn) => {
|
|
112
216
|
if (c.held.length === 0) return;
|
|
@@ -117,38 +221,83 @@ const releaseHeld = (c: Conn) => {
|
|
|
117
221
|
|
|
118
222
|
const cmdsOf = (env: StatewireEnvelope): StatewireCmd[] => {
|
|
119
223
|
if (env.cmd === undefined) return [];
|
|
120
|
-
if (!Array.isArray(env.cmd)) return protocolError("cmd must be an array");
|
|
121
224
|
return env.cmd;
|
|
122
225
|
};
|
|
123
226
|
|
|
227
|
+
const snapshotOf = (env: StatewireEnvelope): unknown[] => {
|
|
228
|
+
const ops = env.ops;
|
|
229
|
+
if (ops === undefined) return protocolError("hello missing snapshot");
|
|
230
|
+
if (ops.length !== 1 || !isObject(ops[0])) {
|
|
231
|
+
return protocolError("hello snapshot must be one root replace op");
|
|
232
|
+
}
|
|
233
|
+
const op = ops[0];
|
|
234
|
+
if (
|
|
235
|
+
op["op"] !== "replace" ||
|
|
236
|
+
!Array.isArray(op["path"]) ||
|
|
237
|
+
op["path"].length !== 0 ||
|
|
238
|
+
!Object.hasOwn(op, "value")
|
|
239
|
+
) {
|
|
240
|
+
return protocolError("hello snapshot must be one root replace op");
|
|
241
|
+
}
|
|
242
|
+
return reviveOps(ops);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const helloSynOf = (env: StatewireEnvelope): StatewireSyn => {
|
|
246
|
+
if (!isObject(env.syn)) return protocolError("expected hello");
|
|
247
|
+
validateSynScalars(env.syn);
|
|
248
|
+
if (env.syn["retry"] !== undefined) {
|
|
249
|
+
protocolError("hello syn cannot carry retry");
|
|
250
|
+
}
|
|
251
|
+
return env.syn as StatewireSyn;
|
|
252
|
+
};
|
|
253
|
+
|
|
124
254
|
const handleHello = (c: Conn, env: StatewireEnvelope) => {
|
|
125
|
-
const syn = env
|
|
126
|
-
|
|
127
|
-
c.awaitingHello = false;
|
|
128
|
-
c.hasSnapshot = true;
|
|
255
|
+
const syn = helloSynOf(env);
|
|
256
|
+
const ops = snapshotOf(env);
|
|
129
257
|
c.ctx.onHello({
|
|
130
|
-
ops
|
|
258
|
+
ops,
|
|
131
259
|
syn,
|
|
132
260
|
});
|
|
133
261
|
c.ctx.onIdle(syn.idle ?? false);
|
|
134
|
-
c.
|
|
262
|
+
c.awaitingHello = false;
|
|
263
|
+
c.hasSnapshot = true;
|
|
264
|
+
c.liveAt = Date.now();
|
|
265
|
+
c.authDenials = 0;
|
|
135
266
|
c.episode = null;
|
|
136
267
|
setStatus(c, { status: "live" });
|
|
137
268
|
};
|
|
138
269
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
270
|
+
let warnedUnknownSynFact = false;
|
|
271
|
+
|
|
272
|
+
const warnUnknownSynFact = () => {
|
|
273
|
+
if (warnedUnknownSynFact) return;
|
|
274
|
+
warnedUnknownSynFact = true;
|
|
275
|
+
console.warn("statewire: ignoring unknown syn fact");
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const deliverSyn = (c: Conn, value: unknown) => {
|
|
279
|
+
if (!isObject(value)) return protocolError("syn must be an object");
|
|
280
|
+
validateSynScalars(value);
|
|
281
|
+
if (value["record"] !== undefined || value["idle"] !== undefined) {
|
|
282
|
+
protocolError("invalid mid-stream syn");
|
|
283
|
+
}
|
|
284
|
+
if (
|
|
285
|
+
value["seq"] !== undefined ||
|
|
286
|
+
value["ctxSeq"] !== undefined ||
|
|
287
|
+
value["retry"] !== undefined
|
|
288
|
+
) {
|
|
289
|
+
c.ctx.onRefusal(validateRefusalSyn(value));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (value["id"] !== undefined) return;
|
|
293
|
+
if (Object.keys(value).length === 0) {
|
|
294
|
+
protocolError("invalid mid-stream syn");
|
|
151
295
|
}
|
|
296
|
+
warnUnknownSynFact();
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const deliverEnvelope = (c: Conn, env: StatewireEnvelope) => {
|
|
300
|
+
if (env.syn !== undefined) deliverSyn(c, env.syn);
|
|
152
301
|
if (env.ops !== undefined) {
|
|
153
302
|
const held = c.held;
|
|
154
303
|
c.held = [];
|
|
@@ -164,9 +313,20 @@ const deliverEnvelope = (c: Conn, env: StatewireEnvelope) => {
|
|
|
164
313
|
if (typeof env.idle === "boolean") c.ctx.onIdle(env.idle);
|
|
165
314
|
};
|
|
166
315
|
|
|
167
|
-
const
|
|
316
|
+
const transientCauseOf = (
|
|
168
317
|
reason: StatewireHost.Fin["reason"],
|
|
169
|
-
):
|
|
318
|
+
): Episode["cause"] => {
|
|
319
|
+
switch (reason) {
|
|
320
|
+
case "evicted":
|
|
321
|
+
return "evicted";
|
|
322
|
+
case "error":
|
|
323
|
+
return "error";
|
|
324
|
+
case "unauthorized":
|
|
325
|
+
return "unauthorized";
|
|
326
|
+
default:
|
|
327
|
+
return "error";
|
|
328
|
+
}
|
|
329
|
+
};
|
|
170
330
|
|
|
171
331
|
const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
|
|
172
332
|
let finish = fin;
|
|
@@ -182,12 +342,20 @@ const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
|
|
|
182
342
|
setStatus(c, { status: "idle" });
|
|
183
343
|
return "idle";
|
|
184
344
|
}
|
|
185
|
-
|
|
186
|
-
|
|
345
|
+
// An auth denial gets one immediate redial; a consecutive repeat parks as gone.
|
|
346
|
+
const denial = finish.reason === "unauthorized";
|
|
347
|
+
const terminal =
|
|
348
|
+
finish.reason === "gone" ||
|
|
349
|
+
finish.reason === "superseded" ||
|
|
350
|
+
(denial && c.authDenials > 0);
|
|
351
|
+
if (denial) c.authDenials += 1;
|
|
352
|
+
c.ctx.onFinish(terminal ? finish : { ...finish, reason: "error" });
|
|
353
|
+
if (!terminal) {
|
|
187
354
|
c.episode = {
|
|
188
|
-
cause: finish.reason,
|
|
355
|
+
cause: transientCauseOf(finish.reason),
|
|
189
356
|
...(finish.message !== undefined && { message: finish.message }),
|
|
190
357
|
};
|
|
358
|
+
if (denial) openGate(c.gate);
|
|
191
359
|
return "reattach";
|
|
192
360
|
}
|
|
193
361
|
setStatus(c, {
|
|
@@ -200,11 +368,9 @@ const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
|
|
|
200
368
|
|
|
201
369
|
export const handleEnvelope = (
|
|
202
370
|
c: Conn,
|
|
203
|
-
|
|
371
|
+
value: unknown,
|
|
204
372
|
): AttemptOutcome | null => {
|
|
205
|
-
|
|
206
|
-
return protocolError("packet must be an object");
|
|
207
|
-
}
|
|
373
|
+
const env = validateEnvelope(value);
|
|
208
374
|
if (c.awaitingHello) {
|
|
209
375
|
if (env.fin !== undefined) return settleFin(c, env.fin);
|
|
210
376
|
handleHello(c, env);
|
|
@@ -225,18 +391,20 @@ const scheduleRetry = (
|
|
|
225
391
|
failure: string | undefined,
|
|
226
392
|
immediate: boolean,
|
|
227
393
|
) => {
|
|
394
|
+
if (c.liveAt !== null && Date.now() - c.liveAt >= timing.backoffResetMs) {
|
|
395
|
+
c.attempt = 0;
|
|
396
|
+
}
|
|
397
|
+
c.liveAt = null;
|
|
228
398
|
c.attempt += 1;
|
|
229
399
|
c.episode ??= { cause: "dropped" };
|
|
230
400
|
const delay = immediate ? 0 : backoff(c.attempt);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
});
|
|
239
|
-
}
|
|
401
|
+
setStatus(c, {
|
|
402
|
+
status: "reconnecting",
|
|
403
|
+
...c.episode,
|
|
404
|
+
attempt: c.attempt,
|
|
405
|
+
nextRetryAt: Date.now() + delay,
|
|
406
|
+
...(failure !== undefined && { lastError: failure }),
|
|
407
|
+
});
|
|
240
408
|
return delay;
|
|
241
409
|
};
|
|
242
410
|
|
|
@@ -244,10 +412,10 @@ const reconnectConn = (c: Conn) => {
|
|
|
244
412
|
if (!c.lifetime) return;
|
|
245
413
|
if (c.connection.status === "gone" || c.connection.status === "idle") {
|
|
246
414
|
c.attempt = 0;
|
|
415
|
+
c.authDenials = 0;
|
|
247
416
|
c.episode = { cause: "dropped" };
|
|
248
417
|
flushTapSync(() => {
|
|
249
|
-
|
|
250
|
-
else setStatus(c, { status: "connecting" });
|
|
418
|
+
scheduleRetry(c, undefined, true);
|
|
251
419
|
});
|
|
252
420
|
}
|
|
253
421
|
openGate(c.gate);
|
|
@@ -303,6 +471,8 @@ export const useStatewireConn = (props: {
|
|
|
303
471
|
hasSnapshot: false,
|
|
304
472
|
lease: null,
|
|
305
473
|
attempt: 0,
|
|
474
|
+
authDenials: 0,
|
|
475
|
+
liveAt: null,
|
|
306
476
|
episode: null,
|
|
307
477
|
awaitingHello: true,
|
|
308
478
|
held: [],
|
|
@@ -325,6 +495,8 @@ export const useStatewireConn = (props: {
|
|
|
325
495
|
c.hasSnapshot = false;
|
|
326
496
|
c.lease = null;
|
|
327
497
|
c.attempt = 0;
|
|
498
|
+
c.authDenials = 0;
|
|
499
|
+
c.liveAt = null;
|
|
328
500
|
c.episode = null;
|
|
329
501
|
if (c.connection.status !== "connecting") {
|
|
330
502
|
setStatus(c, { status: "connecting" });
|
package/src/client/session.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useContext, useEffect, useState } from "react";
|
|
2
2
|
import { applyOps } from "../wire/ops";
|
|
3
3
|
import type { StatewirePath } from "../wire/ops";
|
|
4
4
|
import type { StatewireCmd, StatewireSyn } from "./connection";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
encodeParams,
|
|
7
|
+
encodeValue,
|
|
8
|
+
reviveResponse,
|
|
9
|
+
reviveValue,
|
|
10
|
+
} from "../wire/meta";
|
|
6
11
|
import { bindCommandsHost } from "../host/host";
|
|
7
12
|
import type { CommandsInvocation, CommandsListener } from "../host/host";
|
|
8
13
|
import type { Statewire } from "../types";
|
|
9
14
|
import type { StatewireClient } from "../statewire";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
StatewireSessionStore,
|
|
14
|
-
StatewireSessionStoreOptions,
|
|
15
|
+
import {
|
|
16
|
+
SessionStorageContext,
|
|
17
|
+
createMemorySessionStore,
|
|
15
18
|
} from "./sessionStore";
|
|
16
19
|
import { backoff } from "./timing";
|
|
17
20
|
import { createMethodProxy } from "../util/methodProxy";
|
|
@@ -65,16 +68,19 @@ const rejectedSend = (
|
|
|
65
68
|
|
|
66
69
|
type AttachState = "detached" | "attached" | "finished";
|
|
67
70
|
|
|
68
|
-
export type StatewireSessionOptions =
|
|
71
|
+
export type StatewireSessionOptions = {
|
|
72
|
+
sessionStore?: StatewireClient.SessionStorage | undefined;
|
|
69
73
|
onDone?: (() => void) | undefined;
|
|
70
74
|
};
|
|
71
75
|
|
|
72
76
|
type Session = {
|
|
73
|
-
record:
|
|
77
|
+
record: StatewireClient.SessionRecord;
|
|
74
78
|
ctx: StatewireRemoteProps;
|
|
75
79
|
recordPersisted: boolean;
|
|
76
|
-
store:
|
|
80
|
+
store: StatewireClient.SessionStorage;
|
|
77
81
|
ephemeral: boolean;
|
|
82
|
+
/** An ephemeral record died with its attach: the storage is off for good. */
|
|
83
|
+
deactivated: boolean;
|
|
78
84
|
sessionKnown: boolean;
|
|
79
85
|
durableSeq: number;
|
|
80
86
|
resumeAt: number;
|
|
@@ -128,9 +134,25 @@ const sleepUntilWake = (s: Session, delayMs: number) =>
|
|
|
128
134
|
s.waiters.push(waiter);
|
|
129
135
|
});
|
|
130
136
|
|
|
131
|
-
const setRecord = (s: Session, record:
|
|
137
|
+
const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
|
|
132
138
|
s.record = record;
|
|
133
|
-
if (!s.
|
|
139
|
+
if (!s.deactivated) s.store.save(record);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const backlog = (s: Session): StatewireClient.SessionCommand[] =>
|
|
143
|
+
s.pending
|
|
144
|
+
.filter((p) => p.seq > s.durableSeq)
|
|
145
|
+
.map((p) => ({ seq: p.seq, ...p.command }));
|
|
146
|
+
|
|
147
|
+
// Storage holds what a reload must resend: a command leaves at its dur, at a
|
|
148
|
+
// server verdict, or at a record reset — never at a local channel death.
|
|
149
|
+
const syncBacklog = (s: Session) => {
|
|
150
|
+
const commands = backlog(s);
|
|
151
|
+
const unchanged =
|
|
152
|
+
commands.length === s.record.commands.length &&
|
|
153
|
+
commands.every((c, i) => c.seq === s.record.commands[i]!.seq);
|
|
154
|
+
if (unchanged) return;
|
|
155
|
+
setRecord(s, { ...s.record, commands });
|
|
134
156
|
};
|
|
135
157
|
|
|
136
158
|
const setIdle = (s: Session, level: boolean) => {
|
|
@@ -223,7 +245,7 @@ const resetRecord = (s: Session, clientId: string) => {
|
|
|
223
245
|
}
|
|
224
246
|
s.pending = [];
|
|
225
247
|
s.durableSeq = 0;
|
|
226
|
-
setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1 });
|
|
248
|
+
setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] });
|
|
227
249
|
for (const old of survivors) {
|
|
228
250
|
s.lifecycle?.onCovered?.(old.inv);
|
|
229
251
|
enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
@@ -241,23 +263,19 @@ const ephemeralDetach = (s: Session) => {
|
|
|
241
263
|
};
|
|
242
264
|
|
|
243
265
|
const rejectedFailure = (cmd: StatewireCmd): StatewireClient.SendFailure => {
|
|
244
|
-
const code =
|
|
245
|
-
cmd.code === undefined ||
|
|
246
|
-
cmd.code === "unknown-command" ||
|
|
247
|
-
cmd.code === "invalid-params"
|
|
248
|
-
? cmd.code
|
|
249
|
-
: protocolError(`unknown rejected code: ${cmd.code}`);
|
|
250
266
|
return {
|
|
251
267
|
fate: "rejected",
|
|
252
268
|
cause: "server",
|
|
253
|
-
...(code !== undefined && { code }),
|
|
269
|
+
...(cmd.code !== undefined && { code: cmd.code }),
|
|
254
270
|
...(cmd.message !== undefined && { message: cmd.message }),
|
|
255
271
|
...(cmd.payload !== undefined && { payload: cmd.payload }),
|
|
256
272
|
};
|
|
257
273
|
};
|
|
258
274
|
|
|
259
275
|
const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
260
|
-
if (
|
|
276
|
+
if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) {
|
|
277
|
+
protocolError("cmd.seq must be a positive safe integer");
|
|
278
|
+
}
|
|
261
279
|
if (cmd.dur === true) {
|
|
262
280
|
s.durableSeq = Math.max(s.durableSeq, cmd.seq);
|
|
263
281
|
for (const entry of s.pending) {
|
|
@@ -305,6 +323,7 @@ const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
|
305
323
|
|
|
306
324
|
const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
|
|
307
325
|
for (const cmd of cmds) handleCmd(s, cmd);
|
|
326
|
+
syncBacklog(s);
|
|
308
327
|
wake(s);
|
|
309
328
|
};
|
|
310
329
|
|
|
@@ -360,6 +379,7 @@ const handleHello = (
|
|
|
360
379
|
s.frameFailures = 0;
|
|
361
380
|
s.resumeAt = 0;
|
|
362
381
|
s.ephemeral = record === "ephemeral";
|
|
382
|
+
if (s.ephemeral) s.deactivated = true;
|
|
363
383
|
const [attachCmd, attachCtx] = s.attachLaneSeqs;
|
|
364
384
|
s.attachLaneSeqs = [new Set(), new Set()];
|
|
365
385
|
if (record === "resumed") {
|
|
@@ -372,6 +392,7 @@ const handleHello = (
|
|
|
372
392
|
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
|
|
373
393
|
for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
|
|
374
394
|
snapClocks(s, syn.seq ?? 0, ctxWatermark);
|
|
395
|
+
syncBacklog(s);
|
|
375
396
|
} else {
|
|
376
397
|
resetRecord(s, s.record.clientId);
|
|
377
398
|
}
|
|
@@ -386,6 +407,7 @@ const handleOps = (
|
|
|
386
407
|
) => {
|
|
387
408
|
ingestOps(s, ops);
|
|
388
409
|
for (const cmd of cmds) handleCmd(s, cmd);
|
|
410
|
+
syncBacklog(s);
|
|
389
411
|
publishIngested(s);
|
|
390
412
|
};
|
|
391
413
|
|
|
@@ -402,7 +424,7 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
|
402
424
|
for (const c of s.ctxPending) c.posted = false;
|
|
403
425
|
}
|
|
404
426
|
if (typeof syn.retry === "number") {
|
|
405
|
-
s.resumeAt = Date.now() +
|
|
427
|
+
s.resumeAt = Date.now() + syn.retry;
|
|
406
428
|
}
|
|
407
429
|
const firstCmd = s.pending.find((p) => !p.posted);
|
|
408
430
|
const firstCtx = s.ctxPending.find((c) => !c.posted);
|
|
@@ -562,6 +584,7 @@ const start = (s: Session) => {
|
|
|
562
584
|
s.recordPersisted = true;
|
|
563
585
|
s.store.save(s.record);
|
|
564
586
|
}
|
|
587
|
+
resume(s);
|
|
565
588
|
s.running = true;
|
|
566
589
|
void pump(s);
|
|
567
590
|
};
|
|
@@ -586,7 +609,6 @@ const enqueue = (
|
|
|
586
609
|
reject: (error: StatewireSendError) => void,
|
|
587
610
|
) => {
|
|
588
611
|
const seq = s.record.nextSeq;
|
|
589
|
-
setRecord(s, { ...s.record, nextSeq: seq + 1 });
|
|
590
612
|
const entry: Pending = {
|
|
591
613
|
seq,
|
|
592
614
|
command,
|
|
@@ -604,10 +626,41 @@ const enqueue = (
|
|
|
604
626
|
reject,
|
|
605
627
|
};
|
|
606
628
|
s.pending.push(entry);
|
|
629
|
+
setRecord(s, { ...s.record, nextSeq: seq + 1, commands: backlog(s) });
|
|
607
630
|
s.lifecycle?.onStart(entry.inv);
|
|
608
631
|
void pump(s);
|
|
609
632
|
};
|
|
610
633
|
|
|
634
|
+
// A stored command cannot be told from one that reached the server: attempted
|
|
635
|
+
// keeps a fresh record settling it lost instead of replaying it blind.
|
|
636
|
+
const resume = (s: Session) => {
|
|
637
|
+
const stored = [...s.record.commands].sort((a, b) => a.seq - b.seq);
|
|
638
|
+
for (const { seq, ...command } of stored) {
|
|
639
|
+
const params =
|
|
640
|
+
command.meta === undefined
|
|
641
|
+
? command.params
|
|
642
|
+
: (reviveValue([...command.params], command.meta) as unknown[]);
|
|
643
|
+
const entry: Pending = {
|
|
644
|
+
seq,
|
|
645
|
+
command,
|
|
646
|
+
inv: {
|
|
647
|
+
method: command.method,
|
|
648
|
+
params,
|
|
649
|
+
ts: Date.now(),
|
|
650
|
+
seq,
|
|
651
|
+
clientId: s.record.clientId,
|
|
652
|
+
},
|
|
653
|
+
attempted: true,
|
|
654
|
+
posted: false,
|
|
655
|
+
applied: false,
|
|
656
|
+
resolve: () => {},
|
|
657
|
+
reject: () => {},
|
|
658
|
+
};
|
|
659
|
+
s.pending.push(entry);
|
|
660
|
+
s.lifecycle?.onStart(entry.inv);
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
|
|
611
664
|
const sendCommand = (
|
|
612
665
|
s: Session,
|
|
613
666
|
method: string,
|
|
@@ -657,13 +710,25 @@ export const useStatewireSession = (
|
|
|
657
710
|
options: StatewireSessionOptions,
|
|
658
711
|
): Session => {
|
|
659
712
|
const [defaultStore] = useState(createMemorySessionStore);
|
|
660
|
-
const
|
|
713
|
+
const mounted = useContext(SessionStorageContext);
|
|
714
|
+
if (options.sessionStore !== undefined && mounted !== undefined) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
"statewire: session storage is set twice — drop the transport's sessionStore or the client's storage.session",
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
const store = options.sessionStore ?? mounted ?? defaultStore;
|
|
661
720
|
const [s] = useState((): Session => {
|
|
662
721
|
const stored = store.load();
|
|
722
|
+
if (stored !== null && !Array.isArray(stored.commands)) {
|
|
723
|
+
throw new Error(
|
|
724
|
+
"statewire: session storage loaded a record without a commands array",
|
|
725
|
+
);
|
|
726
|
+
}
|
|
663
727
|
const record = stored ?? {
|
|
664
728
|
clientId: newClientId(),
|
|
665
729
|
nextSeq: 1,
|
|
666
730
|
ctxNextSeq: 1,
|
|
731
|
+
commands: [],
|
|
667
732
|
};
|
|
668
733
|
const session: Session = {
|
|
669
734
|
record,
|
|
@@ -683,6 +748,7 @@ export const useStatewireSession = (
|
|
|
683
748
|
recordPersisted: stored !== null,
|
|
684
749
|
store,
|
|
685
750
|
ephemeral: false,
|
|
751
|
+
deactivated: false,
|
|
686
752
|
sessionKnown: false,
|
|
687
753
|
durableSeq: 0,
|
|
688
754
|
resumeAt: 0,
|
|
@@ -1,20 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
nextSeq: number;
|
|
4
|
-
ctxNextSeq: number;
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
export type StatewireSessionStore = {
|
|
8
|
-
load: () => StatewireSessionRecord | null;
|
|
9
|
-
save: (record: StatewireSessionRecord) => void;
|
|
10
|
-
};
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
import type { StatewireClient } from "../statewire";
|
|
11
3
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
4
|
+
/** The session storage `useStatewire` mounts, read by the transport's session. */
|
|
5
|
+
export const SessionStorageContext = createContext<
|
|
6
|
+
StatewireClient.SessionStorage | undefined
|
|
7
|
+
>(undefined);
|
|
15
8
|
|
|
16
|
-
export const createMemorySessionStore = ():
|
|
17
|
-
let record:
|
|
9
|
+
export const createMemorySessionStore = (): StatewireClient.SessionStorage => {
|
|
10
|
+
let record: StatewireClient.SessionRecord | null = null;
|
|
18
11
|
return {
|
|
19
12
|
load: () => record,
|
|
20
13
|
save: (next) => {
|
package/src/client/timing.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
export const timing = {
|
|
2
2
|
backoffBaseMs: 250,
|
|
3
3
|
backoffMaxMs: 30_000,
|
|
4
|
+
backoffResetMs: 30_000,
|
|
4
5
|
heartbeatTimeoutMs: 30_000,
|
|
6
|
+
random: Math.random,
|
|
5
7
|
};
|
|
6
8
|
|
|
7
|
-
export const backoff = (attempt: number) =>
|
|
8
|
-
Math.min(
|
|
9
|
+
export const backoff = (attempt: number) => {
|
|
10
|
+
const limit = Math.min(
|
|
11
|
+
timing.backoffBaseMs * 2 ** (attempt - 1),
|
|
12
|
+
timing.backoffMaxMs,
|
|
13
|
+
);
|
|
14
|
+
const floor = Math.min(timing.backoffBaseMs, limit);
|
|
15
|
+
return Math.round(floor + (limit - floor) * timing.random());
|
|
16
|
+
};
|