statewire 0.14.0 → 0.15.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/README.md +29 -80
- package/dist/client/connection.d.ts +19 -6
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +79 -40
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +1 -0
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +5 -0
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +2 -0
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +96 -16
- package/dist/client/session.js.map +1 -1
- package/dist/host/adapter.d.ts +1 -1
- package/dist/host/adapter.d.ts.map +1 -1
- package/dist/host/adapter.js +7 -1
- package/dist/host/adapter.js.map +1 -1
- package/dist/host/host.d.ts +1 -0
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +45 -5
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts +1 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +75 -47
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +23 -7
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +275 -74
- package/dist/host/socket.js.map +1 -1
- package/dist/host/work.d.ts +7 -0
- package/dist/host/work.d.ts.map +1 -0
- package/dist/host/work.js +53 -0
- package/dist/host/work.js.map +1 -0
- package/dist/host-internal.d.ts +35 -5
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host-internal.js +2 -1
- package/dist/host.d.ts +16 -3
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +2 -1
- package/dist/statewire.d.ts +11 -2
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js.map +1 -1
- package/dist/transports/sse.d.ts +1 -4
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +47 -56
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.js +2 -2
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +1 -1
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +54 -41
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/documents.d.ts +20 -0
- package/dist/wire/documents.d.ts.map +1 -0
- package/dist/wire/documents.js +74 -0
- package/dist/wire/documents.js.map +1 -0
- package/dist/wire/limits.d.ts +10 -0
- package/dist/wire/limits.d.ts.map +1 -0
- package/dist/wire/limits.js +11 -0
- package/dist/wire/limits.js.map +1 -0
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +1 -1
- package/dist/wire/meta.js.map +1 -1
- package/dist/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +1 -2
- package/dist/wire/ops.js.map +1 -1
- package/dist/wire/version.d.ts +7 -0
- package/dist/wire/version.d.ts.map +1 -0
- package/dist/wire/version.js +8 -0
- package/dist/wire/version.js.map +1 -0
- package/package.json +1 -1
- package/src/client/connection.ts +159 -52
- package/src/client/core.ts +6 -0
- package/src/client/session.ts +104 -29
- package/src/host/adapter.ts +10 -2
- package/src/host/host.ts +54 -4
- package/src/host/http.ts +132 -66
- package/src/host/socket.ts +410 -81
- package/src/host/work.ts +50 -0
- package/src/host-internal.ts +32 -4
- package/src/host.ts +18 -2
- package/src/statewire.ts +16 -2
- package/src/transports/sse.ts +60 -68
- package/src/transports/testing.ts +2 -2
- package/src/transports/ws.ts +29 -48
- package/src/types.ts +1 -0
- package/src/wire/documents.ts +109 -0
- package/src/wire/limits.ts +6 -0
- package/src/wire/meta.ts +6 -1
- package/src/wire/ops.ts +1 -2
- package/src/wire/version.ts +3 -0
package/src/client/connection.ts
CHANGED
|
@@ -2,13 +2,20 @@ import { useEffect, useRef, useState } from "react";
|
|
|
2
2
|
import { flushTapSync } from "@assistant-ui/tap";
|
|
3
3
|
import { reviveOps, reviveValue } from "../wire/meta";
|
|
4
4
|
import { backoff, timing } from "./timing";
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
ConnectionState,
|
|
7
|
+
RemoteLink,
|
|
8
|
+
StatewireFrame,
|
|
9
|
+
StatewireRemoteProps,
|
|
10
|
+
StatewireSubmitResult,
|
|
11
|
+
} from "../transports/sse";
|
|
6
12
|
import type { StatewireClient } from "../statewire";
|
|
7
13
|
import type { StatewireHost } from "../host";
|
|
8
14
|
|
|
9
15
|
export type Episode = {
|
|
10
|
-
cause: "dropped" | "
|
|
16
|
+
cause: "dropped" | "reconnect" | "error" | "unauthorized";
|
|
11
17
|
message?: string;
|
|
18
|
+
code?: string;
|
|
12
19
|
};
|
|
13
20
|
|
|
14
21
|
/** The terminal cmd verdict types; "applied" is the sole non-terminal type. */
|
|
@@ -90,11 +97,13 @@ export type AttemptOutcome = "stop" | "reattach" | "ended" | "idle";
|
|
|
90
97
|
export type Conn = {
|
|
91
98
|
connection: StatewireClient.Connection;
|
|
92
99
|
parked: boolean;
|
|
93
|
-
hasSnapshot: boolean;
|
|
94
100
|
lease: string | null;
|
|
95
101
|
attempt: number;
|
|
96
102
|
authDenials: number;
|
|
103
|
+
authStale: boolean;
|
|
97
104
|
liveAt: number | null;
|
|
105
|
+
retryAt: number;
|
|
106
|
+
error: StatewireClient.ConnectionError | undefined;
|
|
98
107
|
episode: Episode | null;
|
|
99
108
|
awaitingHello: boolean;
|
|
100
109
|
held: StatewireCmd[];
|
|
@@ -102,6 +111,7 @@ export type Conn = {
|
|
|
102
111
|
abort: AbortController | null;
|
|
103
112
|
lifetime: AbortController | null;
|
|
104
113
|
ctx: StatewireRemoteProps;
|
|
114
|
+
onError: (error: StatewireClient.ConnectionError) => void;
|
|
105
115
|
onChange: (connection: StatewireClient.Connection) => void;
|
|
106
116
|
attemptOnce: (abort: AbortController) => Promise<AttemptOutcome>;
|
|
107
117
|
notify: (connection: StatewireClient.Connection) => void;
|
|
@@ -110,7 +120,9 @@ export type Conn = {
|
|
|
110
120
|
|
|
111
121
|
export const degradedOf = (state: ConnectionState): boolean =>
|
|
112
122
|
(state.status === "connecting" && state.attempt > 0) ||
|
|
113
|
-
(state.status === "disconnected" &&
|
|
123
|
+
(state.status === "disconnected" &&
|
|
124
|
+
state.reason !== "idle" &&
|
|
125
|
+
state.reason !== "deferred");
|
|
114
126
|
|
|
115
127
|
const sameState = (a: ConnectionState, b: ConnectionState): boolean => {
|
|
116
128
|
if (a.status !== b.status) return false;
|
|
@@ -118,6 +130,7 @@ const sameState = (a: ConnectionState, b: ConnectionState): boolean => {
|
|
|
118
130
|
return (
|
|
119
131
|
a.attempt === b.attempt &&
|
|
120
132
|
a.cause === b.cause &&
|
|
133
|
+
a.code === b.code &&
|
|
121
134
|
a.nextRetryAt === b.nextRetryAt &&
|
|
122
135
|
a.lastError === b.lastError
|
|
123
136
|
);
|
|
@@ -129,9 +142,10 @@ const sameState = (a: ConnectionState, b: ConnectionState): boolean => {
|
|
|
129
142
|
};
|
|
130
143
|
|
|
131
144
|
const setStatus = (c: Conn, next: ConnectionState) => {
|
|
132
|
-
if (sameState(next, c.connection)) return;
|
|
145
|
+
if (sameState(next, c.connection) && c.connection.error === c.error) return;
|
|
133
146
|
c.connection = {
|
|
134
147
|
...next,
|
|
148
|
+
...(c.error === undefined ? {} : { error: c.error }),
|
|
135
149
|
reconnect: c.reconnect,
|
|
136
150
|
degraded: degradedOf(next),
|
|
137
151
|
};
|
|
@@ -215,6 +229,14 @@ const validateFin = (value: unknown): value is StatewireHost.Fin => {
|
|
|
215
229
|
protocolError("fin.reason must be a string");
|
|
216
230
|
}
|
|
217
231
|
validateOptionalString(value["message"], "fin.message");
|
|
232
|
+
validateOptionalString(value["code"], "fin.code");
|
|
233
|
+
if (
|
|
234
|
+
value["status"] !== undefined &&
|
|
235
|
+
!isNonnegativeSafeInteger(value["status"])
|
|
236
|
+
)
|
|
237
|
+
protocolError("fin.status must be a nonnegative safe integer");
|
|
238
|
+
if (value["retry"] !== undefined && !isNonnegativeSafeInteger(value["retry"]))
|
|
239
|
+
protocolError("fin.retry must be a nonnegative safe integer");
|
|
218
240
|
return true;
|
|
219
241
|
};
|
|
220
242
|
|
|
@@ -261,18 +283,7 @@ const cmdsOf = (env: StatewireEnvelope): StatewireCmd[] => {
|
|
|
261
283
|
const snapshotOf = (env: StatewireEnvelope): unknown[] => {
|
|
262
284
|
const ops = env.ops;
|
|
263
285
|
if (ops === undefined) return protocolError("hello missing snapshot");
|
|
264
|
-
if (ops.length
|
|
265
|
-
return protocolError("hello snapshot must be one root replace op");
|
|
266
|
-
}
|
|
267
|
-
const op = ops[0];
|
|
268
|
-
if (
|
|
269
|
-
op["op"] !== "replace" ||
|
|
270
|
-
!Array.isArray(op["path"]) ||
|
|
271
|
-
op["path"].length !== 0 ||
|
|
272
|
-
!Object.hasOwn(op, "value")
|
|
273
|
-
) {
|
|
274
|
-
return protocolError("hello snapshot must be one root replace op");
|
|
275
|
-
}
|
|
286
|
+
if (ops.length === 0) return protocolError("hello missing snapshot");
|
|
276
287
|
return reviveOps(ops);
|
|
277
288
|
};
|
|
278
289
|
|
|
@@ -294,7 +305,7 @@ const handleHello = (c: Conn, env: StatewireEnvelope) => {
|
|
|
294
305
|
});
|
|
295
306
|
c.ctx.onIdle(syn.idle ?? false);
|
|
296
307
|
c.awaitingHello = false;
|
|
297
|
-
c.
|
|
308
|
+
c.error = undefined;
|
|
298
309
|
c.liveAt = Date.now();
|
|
299
310
|
c.authDenials = 0;
|
|
300
311
|
c.episode = null;
|
|
@@ -347,51 +358,63 @@ const deliverEnvelope = (c: Conn, env: StatewireEnvelope) => {
|
|
|
347
358
|
if (typeof env.idle === "boolean") c.ctx.onIdle(env.idle);
|
|
348
359
|
};
|
|
349
360
|
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
return "evicted";
|
|
356
|
-
case "error":
|
|
357
|
-
return "error";
|
|
358
|
-
case "unauthorized":
|
|
359
|
-
return "unauthorized";
|
|
360
|
-
default:
|
|
361
|
-
return "error";
|
|
362
|
-
}
|
|
363
|
-
};
|
|
364
|
-
|
|
365
|
-
const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
|
|
366
|
-
let finish = fin;
|
|
367
|
-
if (finish.meta !== undefined) {
|
|
368
|
-
const { meta, ...rest } = finish;
|
|
369
|
-
finish = { ...rest, payload: reviveValue(rest.payload, meta) };
|
|
370
|
-
}
|
|
361
|
+
export const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
|
|
362
|
+
const finish =
|
|
363
|
+
fin.meta === undefined
|
|
364
|
+
? fin
|
|
365
|
+
: { ...fin, payload: reviveValue(fin.payload, fin.meta) };
|
|
371
366
|
releaseHeld(c);
|
|
367
|
+
c.retryAt = Date.now() + (finish.retry ?? 0);
|
|
372
368
|
if (finish.reason === "idle") {
|
|
373
369
|
c.ctx.onIdle(true);
|
|
374
370
|
c.attempt = 0;
|
|
375
371
|
c.episode = null;
|
|
372
|
+
c.error = undefined;
|
|
376
373
|
setStatus(c, { status: "disconnected", reason: "idle" });
|
|
377
374
|
return "idle";
|
|
378
375
|
}
|
|
379
|
-
// An auth denial gets one immediate redial; a consecutive repeat parks as gone.
|
|
380
376
|
const denial = finish.reason === "unauthorized";
|
|
381
|
-
const terminal =
|
|
382
|
-
|
|
383
|
-
|
|
377
|
+
const terminal =
|
|
378
|
+
finish.reason === "gone" ||
|
|
379
|
+
finish.reason === "forbidden" ||
|
|
380
|
+
finish.reason === "protocol-error" ||
|
|
381
|
+
(denial && c.authDenials > 0);
|
|
382
|
+
if (denial) {
|
|
383
|
+
c.authDenials += 1;
|
|
384
|
+
c.authStale = true;
|
|
385
|
+
}
|
|
386
|
+
const report =
|
|
387
|
+
finish.reason !== "reconnect" &&
|
|
388
|
+
finish.reason !== "gone" &&
|
|
389
|
+
(!denial || terminal);
|
|
390
|
+
c.error = report
|
|
391
|
+
? Object.assign(new Error(finish.message ?? finish.reason), { fin: finish })
|
|
392
|
+
: undefined;
|
|
393
|
+
if (c.error) c.onError(c.error);
|
|
384
394
|
if (!terminal) {
|
|
385
395
|
c.episode = {
|
|
386
|
-
cause:
|
|
396
|
+
cause:
|
|
397
|
+
finish.reason === "reconnect"
|
|
398
|
+
? "reconnect"
|
|
399
|
+
: denial
|
|
400
|
+
? "unauthorized"
|
|
401
|
+
: "error",
|
|
387
402
|
...(finish.message !== undefined && { message: finish.message }),
|
|
403
|
+
...(finish.code !== undefined && { code: finish.code }),
|
|
388
404
|
};
|
|
389
405
|
if (denial) openGate(c.gate);
|
|
390
406
|
return "reattach";
|
|
391
407
|
}
|
|
408
|
+
c.ctx.onFinish(finish);
|
|
392
409
|
setStatus(c, {
|
|
393
410
|
status: "disconnected",
|
|
394
|
-
reason:
|
|
411
|
+
reason: denial
|
|
412
|
+
? "unauthorized"
|
|
413
|
+
: finish.reason === "forbidden"
|
|
414
|
+
? "forbidden"
|
|
415
|
+
: finish.reason === "protocol-error"
|
|
416
|
+
? "protocol-error"
|
|
417
|
+
: "gone",
|
|
395
418
|
...(finish.message !== undefined && { message: finish.message }),
|
|
396
419
|
...(finish.payload !== undefined && { payload: finish.payload }),
|
|
397
420
|
});
|
|
@@ -432,8 +455,10 @@ const scheduleRetry = (
|
|
|
432
455
|
c.liveAt = null;
|
|
433
456
|
c.attempt += 1;
|
|
434
457
|
c.episode ??= { cause: "dropped" };
|
|
435
|
-
|
|
436
|
-
|
|
458
|
+
const delay = Math.max(
|
|
459
|
+
immediate ? 0 : backoff(c.attempt),
|
|
460
|
+
c.retryAt - Date.now(),
|
|
461
|
+
);
|
|
437
462
|
setStatus(c, {
|
|
438
463
|
status: "connecting",
|
|
439
464
|
attempt: c.attempt,
|
|
@@ -465,6 +490,10 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
|
|
|
465
490
|
c.parked = false;
|
|
466
491
|
}
|
|
467
492
|
while (alive()) {
|
|
493
|
+
if (c.retryAt > Date.now()) {
|
|
494
|
+
await waitGate(gate, c.retryAt - Date.now());
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
468
497
|
// The dial is in flight: attempt alone, no backoff fields.
|
|
469
498
|
flushTapSync(() => dialing(c));
|
|
470
499
|
const abort = new AbortController();
|
|
@@ -477,6 +506,27 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
|
|
|
477
506
|
outcome = await c.attemptOnce(abort);
|
|
478
507
|
} catch (error) {
|
|
479
508
|
if (!abort.signal.aborted) {
|
|
509
|
+
if (
|
|
510
|
+
error instanceof SyntaxError ||
|
|
511
|
+
(error instanceof Error &&
|
|
512
|
+
/^(statewire: protocol error:|Statewire cannot apply)/.test(
|
|
513
|
+
error.message,
|
|
514
|
+
))
|
|
515
|
+
) {
|
|
516
|
+
outcome = flushTapSync(() =>
|
|
517
|
+
settleFin(c, { reason: "protocol-error", message: error.message }),
|
|
518
|
+
);
|
|
519
|
+
} else if (c.authStale) {
|
|
520
|
+
outcome = flushTapSync(() =>
|
|
521
|
+
settleFin(c, {
|
|
522
|
+
reason: "unauthorized",
|
|
523
|
+
message:
|
|
524
|
+
error instanceof Error
|
|
525
|
+
? error.message
|
|
526
|
+
: "credential refresh failed",
|
|
527
|
+
}),
|
|
528
|
+
);
|
|
529
|
+
}
|
|
480
530
|
failure =
|
|
481
531
|
error instanceof Error && error.message ? error.message : "network";
|
|
482
532
|
}
|
|
@@ -488,6 +538,11 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
|
|
|
488
538
|
c.ctx.onDetach();
|
|
489
539
|
});
|
|
490
540
|
if (!alive()) return;
|
|
541
|
+
if (
|
|
542
|
+
c.connection.status === "disconnected" &&
|
|
543
|
+
!["deferred", "idle"].includes(c.connection.reason)
|
|
544
|
+
)
|
|
545
|
+
outcome = "stop";
|
|
491
546
|
if (outcome === "stop" || outcome === "idle") {
|
|
492
547
|
await waitGate(gate);
|
|
493
548
|
continue;
|
|
@@ -499,24 +554,63 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
|
|
|
499
554
|
}
|
|
500
555
|
};
|
|
501
556
|
|
|
502
|
-
export const
|
|
557
|
+
export const unsupportedVersion = (c: Conn, version: string): AttemptOutcome =>
|
|
558
|
+
flushTapSync(() =>
|
|
559
|
+
handleEnvelope(c, {
|
|
560
|
+
fin: {
|
|
561
|
+
reason: "protocol-error",
|
|
562
|
+
message: `unsupported protocol version: ${version || '""'}`,
|
|
563
|
+
},
|
|
564
|
+
}),
|
|
565
|
+
) ?? "stop";
|
|
566
|
+
|
|
567
|
+
export const resolveHeaders = async (
|
|
568
|
+
headers: StatewireClient.HeadersOption | undefined,
|
|
569
|
+
staleAuth: boolean,
|
|
570
|
+
): Promise<Headers> =>
|
|
571
|
+
new Headers(
|
|
572
|
+
typeof headers === "function" ? await headers({ staleAuth }) : headers,
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
// The stale flag clears only once the resolver returns: a throwing refresh keeps it for the next attempt.
|
|
576
|
+
export const attachFrame = async (
|
|
577
|
+
c: Conn,
|
|
578
|
+
headers: StatewireClient.HeadersOption | undefined,
|
|
579
|
+
) => {
|
|
580
|
+
const resolved = await resolveHeaders(headers, c.authStale);
|
|
581
|
+
c.authStale = false;
|
|
582
|
+
const lanes = flushTapSync(() => c.ctx.takeAttachLanes());
|
|
583
|
+
return {
|
|
584
|
+
headers: resolved,
|
|
585
|
+
body: JSON.stringify({
|
|
586
|
+
headers: Object.fromEntries(resolved),
|
|
587
|
+
...(lanes ?? {}),
|
|
588
|
+
}),
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
export const useLink = (props: {
|
|
503
593
|
url: string;
|
|
504
594
|
isNew?: boolean | undefined;
|
|
505
595
|
onChange?: ((connection: StatewireClient.Connection) => void) | undefined;
|
|
596
|
+
onError?: ((error: StatewireClient.ConnectionError) => void) | undefined;
|
|
506
597
|
ctx: StatewireRemoteProps;
|
|
507
598
|
attemptOnce: (c: Conn, abort: AbortController) => Promise<AttemptOutcome>;
|
|
508
|
-
|
|
599
|
+
send: (c: Conn, frame: StatewireFrame) => Promise<StatewireSubmitResult>;
|
|
600
|
+
}): RemoteLink => {
|
|
509
601
|
const latest = useRef(props);
|
|
510
602
|
latest.current = props;
|
|
511
603
|
const [, setConnection] = useState<StatewireClient.Connection | null>(null);
|
|
512
604
|
const [c] = useState((): Conn => {
|
|
513
605
|
const conn: Conn = {
|
|
514
606
|
parked: props.isNew ?? false,
|
|
515
|
-
hasSnapshot: false,
|
|
516
607
|
lease: null,
|
|
517
608
|
attempt: 0,
|
|
518
609
|
authDenials: 0,
|
|
610
|
+
authStale: false,
|
|
519
611
|
liveAt: null,
|
|
612
|
+
retryAt: 0,
|
|
613
|
+
error: undefined,
|
|
520
614
|
episode: null,
|
|
521
615
|
awaitingHello: true,
|
|
522
616
|
held: [],
|
|
@@ -526,6 +620,7 @@ export const useStatewireConn = (props: {
|
|
|
526
620
|
get ctx() {
|
|
527
621
|
return latest.current.ctx;
|
|
528
622
|
},
|
|
623
|
+
onError: (error) => latest.current.onError?.(error),
|
|
529
624
|
onChange: (connection) => latest.current.onChange?.(connection),
|
|
530
625
|
attemptOnce: (abort) => latest.current.attemptOnce(conn, abort),
|
|
531
626
|
notify: setConnection,
|
|
@@ -540,13 +635,25 @@ export const useStatewireConn = (props: {
|
|
|
540
635
|
};
|
|
541
636
|
return conn;
|
|
542
637
|
});
|
|
638
|
+
const [link] = useState(
|
|
639
|
+
(): RemoteLink => ({
|
|
640
|
+
get connection() {
|
|
641
|
+
return c.connection;
|
|
642
|
+
},
|
|
643
|
+
get parked() {
|
|
644
|
+
return c.parked;
|
|
645
|
+
},
|
|
646
|
+
send: (frame) => latest.current.send(c, frame),
|
|
647
|
+
}),
|
|
648
|
+
);
|
|
543
649
|
|
|
544
650
|
useEffect(() => {
|
|
545
|
-
c.hasSnapshot = false;
|
|
546
651
|
c.lease = null;
|
|
547
652
|
c.attempt = 0;
|
|
548
653
|
c.authDenials = 0;
|
|
654
|
+
c.authStale = false;
|
|
549
655
|
c.liveAt = null;
|
|
656
|
+
c.retryAt = 0;
|
|
550
657
|
c.episode = null;
|
|
551
658
|
setStatus(
|
|
552
659
|
c,
|
|
@@ -566,7 +673,7 @@ export const useStatewireConn = (props: {
|
|
|
566
673
|
};
|
|
567
674
|
}, [c, props.url]);
|
|
568
675
|
|
|
569
|
-
return
|
|
676
|
+
return link;
|
|
570
677
|
};
|
|
571
678
|
|
|
572
679
|
export const classify = (timeout: AbortSignal) =>
|
package/src/client/core.ts
CHANGED
|
@@ -42,6 +42,7 @@ type Core = {
|
|
|
42
42
|
notifiedPublished: unknown;
|
|
43
43
|
notifiedOverlayPaths: readonly StatewirePath[];
|
|
44
44
|
notifiedConnection: StatewireClient.Connection | null;
|
|
45
|
+
notifiedError: StatewireClient.ConnectionError | undefined;
|
|
45
46
|
notifiedBusy: boolean;
|
|
46
47
|
notifiedFailed: readonly StatewireClient.FailedCommand[];
|
|
47
48
|
stale: boolean;
|
|
@@ -199,6 +200,10 @@ const flush = (c: Core, scheduled = false) => {
|
|
|
199
200
|
const connectionChanged = connection !== c.notifiedConnection;
|
|
200
201
|
c.notifiedConnection = connection;
|
|
201
202
|
if (connectionChanged) options.onConnectionChange?.(connection);
|
|
203
|
+
if (connection.error !== c.notifiedError) {
|
|
204
|
+
c.notifiedError = connection.error;
|
|
205
|
+
if (connection.error) options.onError?.(connection.error);
|
|
206
|
+
}
|
|
202
207
|
const busy = c.getBusy();
|
|
203
208
|
const busyChanged = busy !== c.notifiedBusy;
|
|
204
209
|
c.notifiedBusy = busy;
|
|
@@ -297,6 +302,7 @@ export const useStatewireCore = (
|
|
|
297
302
|
notifiedPublished: undefined,
|
|
298
303
|
notifiedOverlayPaths: [],
|
|
299
304
|
notifiedConnection: null,
|
|
305
|
+
notifiedError: undefined,
|
|
300
306
|
notifiedBusy: true,
|
|
301
307
|
notifiedFailed: NO_FAILED,
|
|
302
308
|
stale: false,
|
package/src/client/session.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { applyDocumentOps } from "../wire/documents";
|
|
2
|
+
import type { StatewireDocuments } from "../wire/documents";
|
|
1
3
|
import { useContext, useEffect, useState } from "react";
|
|
2
|
-
|
|
4
|
+
|
|
3
5
|
import type { StatewirePath } from "../wire/ops";
|
|
6
|
+
import { MAX_COMMANDS_PER_FRAME } from "../wire/limits";
|
|
4
7
|
import { TERMINAL } from "./connection";
|
|
5
8
|
import type { StatewireCmd, StatewireSyn } from "./connection";
|
|
6
9
|
import {
|
|
@@ -103,6 +106,7 @@ type Session = {
|
|
|
103
106
|
frameFailures: number;
|
|
104
107
|
pending: Pending[];
|
|
105
108
|
canonical: unknown;
|
|
109
|
+
documents: StatewireDocuments.State;
|
|
106
110
|
lastPublished: unknown;
|
|
107
111
|
changed: StatewirePath[];
|
|
108
112
|
source: Statewire.StateSource;
|
|
@@ -238,7 +242,7 @@ export const failedCommands = (
|
|
|
238
242
|
s: Session,
|
|
239
243
|
): readonly StatewireClient.FailedCommand[] => s.record.failed ?? NO_FAILED;
|
|
240
244
|
|
|
241
|
-
const
|
|
245
|
+
const retainFailedInner = (
|
|
242
246
|
s: Session,
|
|
243
247
|
entry: Pending,
|
|
244
248
|
failure: StatewireClient.SendFailure,
|
|
@@ -260,6 +264,18 @@ const retainFailed = (
|
|
|
260
264
|
s.notify();
|
|
261
265
|
};
|
|
262
266
|
|
|
267
|
+
const retainFailed = (
|
|
268
|
+
s: Session,
|
|
269
|
+
entry: Pending,
|
|
270
|
+
failure: StatewireClient.SendFailure,
|
|
271
|
+
) => {
|
|
272
|
+
if (!s.shared) {
|
|
273
|
+
retainFailedInner(s, entry, failure);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
void transact(s, () => retainFailedInner(s, entry, failure));
|
|
277
|
+
};
|
|
278
|
+
|
|
263
279
|
const settleEntry = (
|
|
264
280
|
s: Session,
|
|
265
281
|
entry: Pending,
|
|
@@ -481,8 +497,9 @@ const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
|
|
|
481
497
|
};
|
|
482
498
|
|
|
483
499
|
const ingestOps = (s: Session, ops: unknown[]) => {
|
|
484
|
-
const applied =
|
|
485
|
-
s.
|
|
500
|
+
const applied = applyDocumentOps(s.documents, ops);
|
|
501
|
+
s.documents = applied;
|
|
502
|
+
s.canonical = applied.documents.get(0)?.value;
|
|
486
503
|
s.changed.push(...applied.changedPaths);
|
|
487
504
|
};
|
|
488
505
|
|
|
@@ -546,6 +563,7 @@ const handleHello = (
|
|
|
546
563
|
if (syn.record !== undefined && syn.record !== "ephemeral") {
|
|
547
564
|
protocolError(`unknown record: ${String(syn.record)}`);
|
|
548
565
|
}
|
|
566
|
+
s.documents = { mode: null, documents: new Map() };
|
|
549
567
|
ingestOps(s, ops);
|
|
550
568
|
s.attach = "attached";
|
|
551
569
|
s.attachEpoch += 1;
|
|
@@ -642,7 +660,7 @@ const handleDetach = (s: Session) => {
|
|
|
642
660
|
};
|
|
643
661
|
|
|
644
662
|
const handleFinish = (s: Session, { reason }: { reason: string }) => {
|
|
645
|
-
if (reason === "
|
|
663
|
+
if (reason === "reconnect" || reason === "error") return;
|
|
646
664
|
s.attach = "finished";
|
|
647
665
|
settleAll(s, "gone");
|
|
648
666
|
publish(s);
|
|
@@ -663,6 +681,7 @@ const buildLanes = (s: Session): Lanes | null => {
|
|
|
663
681
|
const ctxEntries: CtxPending[] = [];
|
|
664
682
|
for (const entry of s.ctxPending) {
|
|
665
683
|
if (entry.posted) continue;
|
|
684
|
+
if (ctxEntries.length >= MAX_COMMANDS_PER_FRAME) break;
|
|
666
685
|
if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1)!.seq + 1)
|
|
667
686
|
break;
|
|
668
687
|
ctxEntries.push(entry);
|
|
@@ -670,6 +689,7 @@ const buildLanes = (s: Session): Lanes | null => {
|
|
|
670
689
|
const cmdEntries: Pending[] = [];
|
|
671
690
|
for (const entry of s.pending) {
|
|
672
691
|
if (entry.posted) continue;
|
|
692
|
+
if (cmdEntries.length >= MAX_COMMANDS_PER_FRAME) break;
|
|
673
693
|
if (cmdEntries.length > 0 && entry.seq !== cmdEntries.at(-1)!.seq + 1)
|
|
674
694
|
break;
|
|
675
695
|
cmdEntries.push(entry);
|
|
@@ -862,9 +882,17 @@ const enqueue = (
|
|
|
862
882
|
void pump(s);
|
|
863
883
|
return;
|
|
864
884
|
}
|
|
865
|
-
void transact(s, () =>
|
|
866
|
-
|
|
867
|
-
|
|
885
|
+
void transact(s, () => {
|
|
886
|
+
if (!s.running) {
|
|
887
|
+
reject(new StatewireSendError({ fate: "aborted", cause: "unmount" }));
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (isFinished(s)) {
|
|
891
|
+
reject(new StatewireSendError({ fate: "aborted", cause: "stream-gone" }));
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
enqueueInner(s, command, params, resolve, reject);
|
|
895
|
+
}).then(() => {
|
|
868
896
|
wake(s);
|
|
869
897
|
void pump(s);
|
|
870
898
|
});
|
|
@@ -928,18 +956,25 @@ const sendCommand = (
|
|
|
928
956
|
return result;
|
|
929
957
|
};
|
|
930
958
|
|
|
931
|
-
const
|
|
959
|
+
const findFailed = (
|
|
932
960
|
s: Session,
|
|
933
961
|
entry: StatewireClient.FailedCommand,
|
|
934
|
-
): StatewireClient.FailedCommand =>
|
|
935
|
-
|
|
936
|
-
const found = failed.find(
|
|
962
|
+
): StatewireClient.FailedCommand | undefined =>
|
|
963
|
+
(s.record.failed ?? []).find(
|
|
937
964
|
(f) =>
|
|
938
965
|
f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command,
|
|
939
966
|
);
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
}
|
|
967
|
+
|
|
968
|
+
const unknownFailed = (entry: StatewireClient.FailedCommand) =>
|
|
969
|
+
new Error(`statewire: unknown failed command "${entry.command}"`);
|
|
970
|
+
|
|
971
|
+
const takeFailed = (
|
|
972
|
+
s: Session,
|
|
973
|
+
entry: StatewireClient.FailedCommand,
|
|
974
|
+
): StatewireClient.FailedCommand => {
|
|
975
|
+
const failed = s.record.failed ?? [];
|
|
976
|
+
const found = findFailed(s, entry);
|
|
977
|
+
if (found === undefined) throw unknownFailed(entry);
|
|
943
978
|
setRecord(s, { ...s.record, failed: failed.filter((f) => f !== found) });
|
|
944
979
|
s.notify();
|
|
945
980
|
return found;
|
|
@@ -949,7 +984,14 @@ export const discardFailed = (
|
|
|
949
984
|
s: Session,
|
|
950
985
|
entry: StatewireClient.FailedCommand,
|
|
951
986
|
): void => {
|
|
952
|
-
|
|
987
|
+
if (!s.shared) {
|
|
988
|
+
takeFailed(s, entry);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
if (findFailed(s, entry) === undefined) throw unknownFailed(entry);
|
|
992
|
+
void transact(s, () => {
|
|
993
|
+
if (findFailed(s, entry) !== undefined) takeFailed(s, entry);
|
|
994
|
+
});
|
|
953
995
|
};
|
|
954
996
|
|
|
955
997
|
export const retryFailed = (
|
|
@@ -962,23 +1004,52 @@ export const retryFailed = (
|
|
|
962
1004
|
if (isFinished(s)) {
|
|
963
1005
|
return rejectedSend({ fate: "aborted", cause: "stream-gone" });
|
|
964
1006
|
}
|
|
965
|
-
const found =
|
|
1007
|
+
const found = findFailed(s, entry);
|
|
1008
|
+
if (found === undefined) throw unknownFailed(entry);
|
|
966
1009
|
const params =
|
|
967
1010
|
found.meta === undefined
|
|
968
1011
|
? found.params
|
|
969
1012
|
: (reviveValue([...found.params], found.meta) as unknown[]);
|
|
970
1013
|
const result = new Promise<unknown>((resolve, reject) => {
|
|
971
|
-
|
|
972
|
-
s
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1014
|
+
const retry = () => {
|
|
1015
|
+
if (!s.running) {
|
|
1016
|
+
reject(new StatewireSendError({ fate: "aborted", cause: "unmount" }));
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
if (isFinished(s)) {
|
|
1020
|
+
reject(
|
|
1021
|
+
new StatewireSendError({ fate: "aborted", cause: "stream-gone" }),
|
|
1022
|
+
);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const current = findFailed(s, entry);
|
|
1026
|
+
if (current === undefined) {
|
|
1027
|
+
reject(unknownFailed(entry));
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
takeFailed(s, current);
|
|
1031
|
+
enqueueInner(
|
|
1032
|
+
s,
|
|
1033
|
+
{
|
|
1034
|
+
method: current.command,
|
|
1035
|
+
params: [...current.params],
|
|
1036
|
+
...(current.meta !== undefined && { meta: current.meta }),
|
|
1037
|
+
},
|
|
1038
|
+
params,
|
|
1039
|
+
resolve,
|
|
1040
|
+
reject,
|
|
1041
|
+
);
|
|
1042
|
+
};
|
|
1043
|
+
if (!s.shared) {
|
|
1044
|
+
retry();
|
|
1045
|
+
wake(s);
|
|
1046
|
+
void pump(s);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
void transact(s, retry).then(() => {
|
|
1050
|
+
wake(s);
|
|
1051
|
+
void pump(s);
|
|
1052
|
+
}, reject);
|
|
982
1053
|
});
|
|
983
1054
|
result.catch(() => {});
|
|
984
1055
|
return result;
|
|
@@ -1010,7 +1081,10 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
|
|
|
1010
1081
|
void pump(s);
|
|
1011
1082
|
return;
|
|
1012
1083
|
}
|
|
1013
|
-
void transact(s,
|
|
1084
|
+
void transact(s, () => {
|
|
1085
|
+
if (!s.running || isFinished(s)) return;
|
|
1086
|
+
mint();
|
|
1087
|
+
}).then(() => {
|
|
1014
1088
|
wake(s);
|
|
1015
1089
|
void pump(s);
|
|
1016
1090
|
});
|
|
@@ -1081,6 +1155,7 @@ export const useStatewireSession = (
|
|
|
1081
1155
|
frameFailures: 0,
|
|
1082
1156
|
pending: [],
|
|
1083
1157
|
canonical: undefined,
|
|
1158
|
+
documents: { mode: null, documents: new Map() },
|
|
1084
1159
|
lastPublished: undefined,
|
|
1085
1160
|
changed: [],
|
|
1086
1161
|
source: {
|
package/src/host/adapter.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CLIENT_ID_RE } from "./socket";
|
|
1
2
|
import type { StatewireHostInternal } from "../host-internal";
|
|
2
3
|
|
|
3
4
|
export const CLIENT_QUERY_PARAM = "client";
|
|
@@ -20,14 +21,21 @@ export const clientIdFromUrl = (url: string | null | undefined): string => {
|
|
|
20
21
|
`statewire: url is missing the "${CLIENT_QUERY_PARAM}" query parameter`,
|
|
21
22
|
);
|
|
22
23
|
}
|
|
24
|
+
if (!CLIENT_ID_RE.test(clientId))
|
|
25
|
+
throw new Error("statewire: invalid client id");
|
|
23
26
|
return clientId;
|
|
24
27
|
};
|
|
25
28
|
|
|
26
29
|
export const textFrameOrClose = (
|
|
27
|
-
socket: Pick<StatewireHostInternal.Socket, "close">,
|
|
30
|
+
socket: Pick<StatewireHostInternal.Socket, "send" | "close">,
|
|
28
31
|
data: unknown,
|
|
29
32
|
): string | null => {
|
|
30
33
|
if (typeof data === "string") return data;
|
|
31
|
-
socket.
|
|
34
|
+
socket.send(
|
|
35
|
+
JSON.stringify({
|
|
36
|
+
fin: { reason: "protocol-error", message: "malformed frame" },
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
socket.close(1008, "malformed frame");
|
|
32
40
|
return null;
|
|
33
41
|
};
|