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.
@@ -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.syn;
126
- if (syn === undefined) return protocolError("expected hello");
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: env.ops === undefined ? [] : reviveOps(env.ops),
257
+ ops,
131
258
  syn,
132
259
  });
133
260
  c.ctx.onIdle(syn.idle ?? false);
134
- c.attempt = 0;
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
- const deliverEnvelope = (c: Conn, env: StatewireEnvelope) => {
140
- if (env.syn !== undefined) {
141
- const syn = env.syn;
142
- if (
143
- syn.seq !== undefined ||
144
- syn.ctxSeq !== undefined ||
145
- syn.retry !== undefined
146
- ) {
147
- c.ctx.onRefusal(syn);
148
- } else if (syn.id === undefined) {
149
- protocolError("invalid mid-stream syn");
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 isTransientFinish = (
314
+ const transientCauseOf = (
168
315
  reason: StatewireHost.Fin["reason"],
169
- ): reason is "evicted" | "error" => reason === "evicted" || reason === "error";
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
- c.ctx.onFinish(finish);
186
- if (isTransientFinish(finish.reason)) {
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
- env: StatewireEnvelope,
362
+ value: unknown,
204
363
  ): AttemptOutcome | null => {
205
- if (typeof env !== "object" || env === null) {
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
- if (c.hasSnapshot) {
232
- setStatus(c, {
233
- status: "reconnecting",
234
- ...c.episode,
235
- attempt: c.attempt,
236
- nextRetryAt: Date.now() + delay,
237
- ...(failure !== undefined && { lastError: failure }),
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
- if (c.hasSnapshot) scheduleRetry(c, undefined, true);
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" });
@@ -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 (typeof cmd.seq !== "number") protocolError("cmd entry missing seq");
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() + Math.max(0, syn.retry);
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);
@@ -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(timing.backoffBaseMs * 2 ** (attempt - 1), timing.backoffMaxMs);
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
@@ -56,6 +56,7 @@ export namespace StatewireHost {
56
56
  reason:
57
57
  | "evicted"
58
58
  | "error"
59
+ | "unauthorized"
59
60
  | "gone"
60
61
  | "superseded"
61
62
  | "idle"
package/src/statewire.ts CHANGED
@@ -198,7 +198,7 @@ export namespace StatewireClient {
198
198
  | {
199
199
  fate: "rejected";
200
200
  cause: "server";
201
- code?: "unknown-command" | "invalid-params";
201
+ code?: string;
202
202
  message?: string;
203
203
  payload?: unknown;
204
204
  }
@@ -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
- if (stale) c.episode = { cause: "unauthorized" };
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
- return header !== undefined && /^\d+$/.test(header)
210
- ? Number(header) * 1000
211
- : null;
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 (
@@ -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 = (event?: { code?: number }) => {
119
+ ws.onclose = () => {
121
120
  closed = true;
122
121
  clearTimeout(timer);
123
122
  if (outcome) resolve(outcome);
124
- else if (event?.code === UNAUTHORIZED_CLOSE) {
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, stale, abort);
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 {