statewire 0.9.0 → 0.9.2
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 +4 -2
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +116 -23
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +3 -4
- package/dist/client/session.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.d.ts +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/statewire.d.ts +1 -1
- 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/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +6 -2
- package/dist/wire/ops.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +200 -40
- package/src/client/session.ts +5 -9
- package/src/client/timing.ts +10 -2
- package/src/host.ts +1 -0
- package/src/statewire.ts +1 -1
- package/src/transports/sse.ts +6 -5
- package/src/transports/ws.ts +4 -9
- package/src/wire/ops.ts +4 -0
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,7 @@ export type Conn = {
|
|
|
83
83
|
hasSnapshot: boolean;
|
|
84
84
|
lease: string | null;
|
|
85
85
|
attempt: number;
|
|
86
|
+
liveAt: number | null;
|
|
86
87
|
episode: Episode | null;
|
|
87
88
|
awaitingHello: boolean;
|
|
88
89
|
held: StatewireCmd[];
|
|
@@ -107,6 +108,108 @@ const protocolError = (message: string): never => {
|
|
|
107
108
|
throw new Error(`statewire: protocol error: ${message}`);
|
|
108
109
|
};
|
|
109
110
|
|
|
111
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
112
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
113
|
+
|
|
114
|
+
const isNonnegativeSafeInteger = (value: unknown): value is number =>
|
|
115
|
+
Number.isSafeInteger(value) && (value as number) >= 0;
|
|
116
|
+
|
|
117
|
+
const isPositiveSafeInteger = (value: unknown): value is number =>
|
|
118
|
+
Number.isSafeInteger(value) && (value as number) > 0;
|
|
119
|
+
|
|
120
|
+
const validateOptionalString = (value: unknown, field: string): void => {
|
|
121
|
+
if (value !== undefined && typeof value !== "string") {
|
|
122
|
+
protocolError(`${field} must be a string`);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const validateSynScalars = (syn: Record<string, unknown>) => {
|
|
127
|
+
if (syn["seq"] !== undefined && !isNonnegativeSafeInteger(syn["seq"])) {
|
|
128
|
+
protocolError("syn.seq must be a nonnegative safe integer");
|
|
129
|
+
}
|
|
130
|
+
if (syn["ctxSeq"] !== undefined && !isNonnegativeSafeInteger(syn["ctxSeq"])) {
|
|
131
|
+
protocolError("syn.ctxSeq must be a nonnegative safe integer");
|
|
132
|
+
}
|
|
133
|
+
if (syn["retry"] !== undefined && !isNonnegativeSafeInteger(syn["retry"])) {
|
|
134
|
+
protocolError("syn.retry must be a nonnegative safe integer");
|
|
135
|
+
}
|
|
136
|
+
validateOptionalString(syn["id"], "syn.id");
|
|
137
|
+
if (syn["idle"] !== undefined && typeof syn["idle"] !== "boolean") {
|
|
138
|
+
protocolError("syn.idle must be a boolean");
|
|
139
|
+
}
|
|
140
|
+
validateOptionalString(syn["record"], "syn.record");
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const validateRefusalSyn = (value: unknown): StatewireSyn => {
|
|
144
|
+
if (!isObject(value)) return protocolError("syn must be an object");
|
|
145
|
+
validateSynScalars(value);
|
|
146
|
+
if (
|
|
147
|
+
value["id"] !== undefined ||
|
|
148
|
+
value["idle"] !== undefined ||
|
|
149
|
+
value["record"] !== undefined
|
|
150
|
+
) {
|
|
151
|
+
protocolError("invalid refusal syn");
|
|
152
|
+
}
|
|
153
|
+
if (
|
|
154
|
+
value["seq"] === undefined &&
|
|
155
|
+
value["ctxSeq"] === undefined &&
|
|
156
|
+
value["retry"] === undefined
|
|
157
|
+
) {
|
|
158
|
+
protocolError("refusal syn has no watermarks or retry");
|
|
159
|
+
}
|
|
160
|
+
return value as StatewireSyn;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const validateCmd = (value: unknown): value is StatewireCmd => {
|
|
164
|
+
if (!isObject(value)) return protocolError("cmd entry must be an object");
|
|
165
|
+
if (!isPositiveSafeInteger(value["seq"])) {
|
|
166
|
+
protocolError("cmd.seq must be a positive safe integer");
|
|
167
|
+
}
|
|
168
|
+
validateOptionalString(value["type"], "cmd.type");
|
|
169
|
+
validateOptionalString(value["code"], "cmd.code");
|
|
170
|
+
validateOptionalString(value["message"], "cmd.message");
|
|
171
|
+
if (value["dur"] !== undefined && value["dur"] !== true) {
|
|
172
|
+
protocolError("cmd.dur must be true");
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const validateFin = (value: unknown): value is StatewireHost.Fin => {
|
|
178
|
+
if (!isObject(value)) return protocolError("fin must be an object");
|
|
179
|
+
if (typeof value["reason"] !== "string") {
|
|
180
|
+
protocolError("fin.reason must be a string");
|
|
181
|
+
}
|
|
182
|
+
validateOptionalString(value["message"], "fin.message");
|
|
183
|
+
return true;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const validateEnvelope = (value: unknown): StatewireEnvelope => {
|
|
187
|
+
if (!isObject(value)) return protocolError("packet must be an object");
|
|
188
|
+
if (value["ops"] !== undefined && !Array.isArray(value["ops"])) {
|
|
189
|
+
protocolError("ops must be an array");
|
|
190
|
+
}
|
|
191
|
+
if (value["cmd"] !== undefined) {
|
|
192
|
+
const cmd = value["cmd"];
|
|
193
|
+
if (!Array.isArray(cmd)) return protocolError("cmd must be an array");
|
|
194
|
+
for (const entry of cmd) validateCmd(entry);
|
|
195
|
+
}
|
|
196
|
+
if (value["idle"] !== undefined && typeof value["idle"] !== "boolean") {
|
|
197
|
+
protocolError("idle must be a boolean");
|
|
198
|
+
}
|
|
199
|
+
if (value["next"] !== undefined) {
|
|
200
|
+
if (value["next"] !== true) protocolError("next must be true");
|
|
201
|
+
if (
|
|
202
|
+
value["cmd"] === undefined ||
|
|
203
|
+
value["ops"] !== undefined ||
|
|
204
|
+
value["fin"] !== undefined
|
|
205
|
+
) {
|
|
206
|
+
protocolError("next must ride a cmd-only packet");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (value["fin"] !== undefined) validateFin(value["fin"]);
|
|
210
|
+
return value as StatewireEnvelope;
|
|
211
|
+
};
|
|
212
|
+
|
|
110
213
|
// A hold's release delivers, never settles: fin and bare close both drain it.
|
|
111
214
|
const releaseHeld = (c: Conn) => {
|
|
112
215
|
if (c.held.length === 0) return;
|
|
@@ -117,38 +220,82 @@ const releaseHeld = (c: Conn) => {
|
|
|
117
220
|
|
|
118
221
|
const cmdsOf = (env: StatewireEnvelope): StatewireCmd[] => {
|
|
119
222
|
if (env.cmd === undefined) return [];
|
|
120
|
-
if (!Array.isArray(env.cmd)) return protocolError("cmd must be an array");
|
|
121
223
|
return env.cmd;
|
|
122
224
|
};
|
|
123
225
|
|
|
226
|
+
const snapshotOf = (env: StatewireEnvelope): unknown[] => {
|
|
227
|
+
const ops = env.ops;
|
|
228
|
+
if (ops === undefined) return protocolError("hello missing snapshot");
|
|
229
|
+
if (ops.length !== 1 || !isObject(ops[0])) {
|
|
230
|
+
return protocolError("hello snapshot must be one root replace op");
|
|
231
|
+
}
|
|
232
|
+
const op = ops[0];
|
|
233
|
+
if (
|
|
234
|
+
op["op"] !== "replace" ||
|
|
235
|
+
!Array.isArray(op["path"]) ||
|
|
236
|
+
op["path"].length !== 0 ||
|
|
237
|
+
!Object.hasOwn(op, "value")
|
|
238
|
+
) {
|
|
239
|
+
return protocolError("hello snapshot must be one root replace op");
|
|
240
|
+
}
|
|
241
|
+
return reviveOps(ops);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const helloSynOf = (env: StatewireEnvelope): StatewireSyn => {
|
|
245
|
+
if (!isObject(env.syn)) return protocolError("expected hello");
|
|
246
|
+
validateSynScalars(env.syn);
|
|
247
|
+
if (env.syn["retry"] !== undefined) {
|
|
248
|
+
protocolError("hello syn cannot carry retry");
|
|
249
|
+
}
|
|
250
|
+
return env.syn as StatewireSyn;
|
|
251
|
+
};
|
|
252
|
+
|
|
124
253
|
const handleHello = (c: Conn, env: StatewireEnvelope) => {
|
|
125
|
-
const syn = env
|
|
126
|
-
|
|
127
|
-
c.awaitingHello = false;
|
|
128
|
-
c.hasSnapshot = true;
|
|
254
|
+
const syn = helloSynOf(env);
|
|
255
|
+
const ops = snapshotOf(env);
|
|
129
256
|
c.ctx.onHello({
|
|
130
|
-
ops
|
|
257
|
+
ops,
|
|
131
258
|
syn,
|
|
132
259
|
});
|
|
133
260
|
c.ctx.onIdle(syn.idle ?? false);
|
|
134
|
-
c.
|
|
261
|
+
c.awaitingHello = false;
|
|
262
|
+
c.hasSnapshot = true;
|
|
263
|
+
c.liveAt = Date.now();
|
|
135
264
|
c.episode = null;
|
|
136
265
|
setStatus(c, { status: "live" });
|
|
137
266
|
};
|
|
138
267
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
268
|
+
let warnedUnknownSynFact = false;
|
|
269
|
+
|
|
270
|
+
const warnUnknownSynFact = () => {
|
|
271
|
+
if (warnedUnknownSynFact) return;
|
|
272
|
+
warnedUnknownSynFact = true;
|
|
273
|
+
console.warn("statewire: ignoring unknown syn fact");
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const deliverSyn = (c: Conn, value: unknown) => {
|
|
277
|
+
if (!isObject(value)) return protocolError("syn must be an object");
|
|
278
|
+
validateSynScalars(value);
|
|
279
|
+
if (value["record"] !== undefined || value["idle"] !== undefined) {
|
|
280
|
+
protocolError("invalid mid-stream syn");
|
|
281
|
+
}
|
|
282
|
+
if (
|
|
283
|
+
value["seq"] !== undefined ||
|
|
284
|
+
value["ctxSeq"] !== undefined ||
|
|
285
|
+
value["retry"] !== undefined
|
|
286
|
+
) {
|
|
287
|
+
c.ctx.onRefusal(validateRefusalSyn(value));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (value["id"] !== undefined) return;
|
|
291
|
+
if (Object.keys(value).length === 0) {
|
|
292
|
+
protocolError("invalid mid-stream syn");
|
|
151
293
|
}
|
|
294
|
+
warnUnknownSynFact();
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const deliverEnvelope = (c: Conn, env: StatewireEnvelope) => {
|
|
298
|
+
if (env.syn !== undefined) deliverSyn(c, env.syn);
|
|
152
299
|
if (env.ops !== undefined) {
|
|
153
300
|
const held = c.held;
|
|
154
301
|
c.held = [];
|
|
@@ -164,9 +311,20 @@ const deliverEnvelope = (c: Conn, env: StatewireEnvelope) => {
|
|
|
164
311
|
if (typeof env.idle === "boolean") c.ctx.onIdle(env.idle);
|
|
165
312
|
};
|
|
166
313
|
|
|
167
|
-
const
|
|
314
|
+
const transientCauseOf = (
|
|
168
315
|
reason: StatewireHost.Fin["reason"],
|
|
169
|
-
):
|
|
316
|
+
): Episode["cause"] => {
|
|
317
|
+
switch (reason) {
|
|
318
|
+
case "evicted":
|
|
319
|
+
return "evicted";
|
|
320
|
+
case "error":
|
|
321
|
+
return "error";
|
|
322
|
+
case "unauthorized":
|
|
323
|
+
return "unauthorized";
|
|
324
|
+
default:
|
|
325
|
+
return "error";
|
|
326
|
+
}
|
|
327
|
+
};
|
|
170
328
|
|
|
171
329
|
const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
|
|
172
330
|
let finish = fin;
|
|
@@ -182,10 +340,11 @@ const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
|
|
|
182
340
|
setStatus(c, { status: "idle" });
|
|
183
341
|
return "idle";
|
|
184
342
|
}
|
|
185
|
-
|
|
186
|
-
|
|
343
|
+
const terminal = finish.reason === "gone" || finish.reason === "superseded";
|
|
344
|
+
c.ctx.onFinish(terminal ? finish : { ...finish, reason: "error" });
|
|
345
|
+
if (!terminal) {
|
|
187
346
|
c.episode = {
|
|
188
|
-
cause: finish.reason,
|
|
347
|
+
cause: transientCauseOf(finish.reason),
|
|
189
348
|
...(finish.message !== undefined && { message: finish.message }),
|
|
190
349
|
};
|
|
191
350
|
return "reattach";
|
|
@@ -200,11 +359,9 @@ const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
|
|
|
200
359
|
|
|
201
360
|
export const handleEnvelope = (
|
|
202
361
|
c: Conn,
|
|
203
|
-
|
|
362
|
+
value: unknown,
|
|
204
363
|
): AttemptOutcome | null => {
|
|
205
|
-
|
|
206
|
-
return protocolError("packet must be an object");
|
|
207
|
-
}
|
|
364
|
+
const env = validateEnvelope(value);
|
|
208
365
|
if (c.awaitingHello) {
|
|
209
366
|
if (env.fin !== undefined) return settleFin(c, env.fin);
|
|
210
367
|
handleHello(c, env);
|
|
@@ -225,18 +382,20 @@ const scheduleRetry = (
|
|
|
225
382
|
failure: string | undefined,
|
|
226
383
|
immediate: boolean,
|
|
227
384
|
) => {
|
|
385
|
+
if (c.liveAt !== null && Date.now() - c.liveAt >= timing.backoffResetMs) {
|
|
386
|
+
c.attempt = 0;
|
|
387
|
+
}
|
|
388
|
+
c.liveAt = null;
|
|
228
389
|
c.attempt += 1;
|
|
229
390
|
c.episode ??= { cause: "dropped" };
|
|
230
391
|
const delay = immediate ? 0 : backoff(c.attempt);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
});
|
|
239
|
-
}
|
|
392
|
+
setStatus(c, {
|
|
393
|
+
status: "reconnecting",
|
|
394
|
+
...c.episode,
|
|
395
|
+
attempt: c.attempt,
|
|
396
|
+
nextRetryAt: Date.now() + delay,
|
|
397
|
+
...(failure !== undefined && { lastError: failure }),
|
|
398
|
+
});
|
|
240
399
|
return delay;
|
|
241
400
|
};
|
|
242
401
|
|
|
@@ -246,8 +405,7 @@ const reconnectConn = (c: Conn) => {
|
|
|
246
405
|
c.attempt = 0;
|
|
247
406
|
c.episode = { cause: "dropped" };
|
|
248
407
|
flushTapSync(() => {
|
|
249
|
-
|
|
250
|
-
else setStatus(c, { status: "connecting" });
|
|
408
|
+
scheduleRetry(c, undefined, true);
|
|
251
409
|
});
|
|
252
410
|
}
|
|
253
411
|
openGate(c.gate);
|
|
@@ -303,6 +461,7 @@ export const useStatewireConn = (props: {
|
|
|
303
461
|
hasSnapshot: false,
|
|
304
462
|
lease: null,
|
|
305
463
|
attempt: 0,
|
|
464
|
+
liveAt: null,
|
|
306
465
|
episode: null,
|
|
307
466
|
awaitingHello: true,
|
|
308
467
|
held: [],
|
|
@@ -325,6 +484,7 @@ export const useStatewireConn = (props: {
|
|
|
325
484
|
c.hasSnapshot = false;
|
|
326
485
|
c.lease = null;
|
|
327
486
|
c.attempt = 0;
|
|
487
|
+
c.liveAt = null;
|
|
328
488
|
c.episode = null;
|
|
329
489
|
if (c.connection.status !== "connecting") {
|
|
330
490
|
setStatus(c, { status: "connecting" });
|
package/src/client/session.ts
CHANGED
|
@@ -241,23 +241,19 @@ const ephemeralDetach = (s: Session) => {
|
|
|
241
241
|
};
|
|
242
242
|
|
|
243
243
|
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
244
|
return {
|
|
251
245
|
fate: "rejected",
|
|
252
246
|
cause: "server",
|
|
253
|
-
...(code !== undefined && { code }),
|
|
247
|
+
...(cmd.code !== undefined && { code: cmd.code }),
|
|
254
248
|
...(cmd.message !== undefined && { message: cmd.message }),
|
|
255
249
|
...(cmd.payload !== undefined && { payload: cmd.payload }),
|
|
256
250
|
};
|
|
257
251
|
};
|
|
258
252
|
|
|
259
253
|
const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
260
|
-
if (
|
|
254
|
+
if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) {
|
|
255
|
+
protocolError("cmd.seq must be a positive safe integer");
|
|
256
|
+
}
|
|
261
257
|
if (cmd.dur === true) {
|
|
262
258
|
s.durableSeq = Math.max(s.durableSeq, cmd.seq);
|
|
263
259
|
for (const entry of s.pending) {
|
|
@@ -402,7 +398,7 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
|
402
398
|
for (const c of s.ctxPending) c.posted = false;
|
|
403
399
|
}
|
|
404
400
|
if (typeof syn.retry === "number") {
|
|
405
|
-
s.resumeAt = Date.now() +
|
|
401
|
+
s.resumeAt = Date.now() + syn.retry;
|
|
406
402
|
}
|
|
407
403
|
const firstCmd = s.pending.find((p) => !p.posted);
|
|
408
404
|
const firstCtx = s.ctxPending.find((c) => !c.posted);
|
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
|
+
};
|
package/src/host.ts
CHANGED
package/src/statewire.ts
CHANGED
package/src/transports/sse.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
classify,
|
|
8
8
|
handleEnvelope,
|
|
9
9
|
useStatewireConn,
|
|
10
|
+
validateRefusalSyn,
|
|
10
11
|
} from "../client/connection";
|
|
11
12
|
import type {
|
|
12
13
|
AttemptOutcome,
|
|
@@ -162,7 +163,7 @@ const attemptStream = async (
|
|
|
162
163
|
clearTimeout(headerTimer);
|
|
163
164
|
}
|
|
164
165
|
if (res.status === 401) {
|
|
165
|
-
|
|
166
|
+
c.episode = { cause: "unauthorized" };
|
|
166
167
|
auth.stale = true;
|
|
167
168
|
throw new Error("unauthorized");
|
|
168
169
|
}
|
|
@@ -206,9 +207,9 @@ const postFrame = async (
|
|
|
206
207
|
// Delta-seconds only; anything else reads as absent.
|
|
207
208
|
const retryAfterMs = (res: Response): number | null => {
|
|
208
209
|
const header = res.headers.get("retry-after")?.trim();
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
if (header === undefined || !/^\d+$/.test(header)) return null;
|
|
211
|
+
const retry = Number(header) * 1000;
|
|
212
|
+
return Number.isSafeInteger(retry) ? retry : null;
|
|
212
213
|
};
|
|
213
214
|
|
|
214
215
|
const refusalSyn = async (res: Response): Promise<StatewireSyn> => {
|
|
@@ -219,7 +220,7 @@ const refusalSyn = async (res: Response): Promise<StatewireSyn> => {
|
|
|
219
220
|
if (typeof syn !== "object" || syn === null) {
|
|
220
221
|
throw new Error(`HTTP ${res.status}`);
|
|
221
222
|
}
|
|
222
|
-
return syn;
|
|
223
|
+
return validateRefusalSyn(syn);
|
|
223
224
|
};
|
|
224
225
|
|
|
225
226
|
const sendFrame = async (
|
package/src/transports/ws.ts
CHANGED
|
@@ -27,7 +27,6 @@ type WsChannel = {
|
|
|
27
27
|
const OPEN = 1;
|
|
28
28
|
|
|
29
29
|
export const WS_SUBPROTOCOL = "statewire.v1";
|
|
30
|
-
const UNAUTHORIZED_CLOSE = 4401;
|
|
31
30
|
|
|
32
31
|
const socketUrl = async (
|
|
33
32
|
options: StatewireClient.WSOptions,
|
|
@@ -64,7 +63,6 @@ const runSocket = (
|
|
|
64
63
|
w: WsChannel,
|
|
65
64
|
ws: WebSocket,
|
|
66
65
|
frame: string,
|
|
67
|
-
staleAuth: boolean,
|
|
68
66
|
abort: AbortController,
|
|
69
67
|
): Promise<AttemptOutcome> =>
|
|
70
68
|
new Promise((resolve, reject) => {
|
|
@@ -110,6 +108,7 @@ const runSocket = (
|
|
|
110
108
|
try {
|
|
111
109
|
const done = flushTapSync(() => handleEnvelope(c, env));
|
|
112
110
|
if (done) {
|
|
111
|
+
if (env.fin?.reason === "unauthorized") w.authStale = true;
|
|
113
112
|
outcome = done;
|
|
114
113
|
ws.close();
|
|
115
114
|
}
|
|
@@ -117,15 +116,11 @@ const runSocket = (
|
|
|
117
116
|
fail(error);
|
|
118
117
|
}
|
|
119
118
|
};
|
|
120
|
-
ws.onclose = (
|
|
119
|
+
ws.onclose = () => {
|
|
121
120
|
closed = true;
|
|
122
121
|
clearTimeout(timer);
|
|
123
122
|
if (outcome) resolve(outcome);
|
|
124
|
-
else
|
|
125
|
-
if (staleAuth) c.episode = { cause: "unauthorized" };
|
|
126
|
-
w.authStale = true;
|
|
127
|
-
reject(new Error("unauthorized"));
|
|
128
|
-
} else reject(new Error(timedOut ? "timeout" : "network"));
|
|
123
|
+
else reject(new Error(timedOut ? "timeout" : "network"));
|
|
129
124
|
};
|
|
130
125
|
});
|
|
131
126
|
|
|
@@ -154,7 +149,7 @@ const attemptSocket = async (
|
|
|
154
149
|
: protocols;
|
|
155
150
|
ws = new WS(url, [WS_SUBPROTOCOL, ...extra]);
|
|
156
151
|
w.socket = ws;
|
|
157
|
-
return await runSocket(c, w, ws, frame,
|
|
152
|
+
return await runSocket(c, w, ws, frame, abort);
|
|
158
153
|
} finally {
|
|
159
154
|
if (w.socket === ws) w.socket = null;
|
|
160
155
|
}
|
package/src/wire/ops.ts
CHANGED
|
@@ -147,6 +147,9 @@ const applyOpToDraft = (box: { state: unknown }, op: Statewire.Op): void => {
|
|
|
147
147
|
}
|
|
148
148
|
};
|
|
149
149
|
|
|
150
|
+
const isMountPlaneOp = (op: { op?: unknown; doc?: unknown }) =>
|
|
151
|
+
"doc" in op || op.op === "mount" || op.op === "unmount";
|
|
152
|
+
|
|
150
153
|
const warnedOps = new Set<unknown>();
|
|
151
154
|
const warnUnknownOp = (type: unknown) => {
|
|
152
155
|
if (warnedOps.has(type)) return;
|
|
@@ -179,6 +182,7 @@ export const applyOps = (
|
|
|
179
182
|
} => {
|
|
180
183
|
const mutable: Statewire.Op[] = [];
|
|
181
184
|
for (const op of ops as Statewire.Op[]) {
|
|
185
|
+
if (isMountPlaneOp(op)) continue;
|
|
182
186
|
if (op.op === "replace" || op.op === "add" || op.op === "remove") {
|
|
183
187
|
mutable.push(op);
|
|
184
188
|
} else {
|