statewire 0.0.1 → 0.0.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.
Files changed (148) hide show
  1. package/README.md +14 -13
  2. package/dist/client/changes.d.ts +2 -2
  3. package/dist/client/changes.d.ts.map +1 -1
  4. package/dist/client/changes.js +3 -10
  5. package/dist/client/changes.js.map +1 -1
  6. package/dist/client/connection.d.ts +18 -39
  7. package/dist/client/connection.d.ts.map +1 -1
  8. package/dist/client/connection.js +61 -54
  9. package/dist/client/connection.js.map +1 -1
  10. package/dist/client/core.d.ts +7 -11
  11. package/dist/client/core.d.ts.map +1 -1
  12. package/dist/client/core.js +58 -55
  13. package/dist/client/core.js.map +1 -1
  14. package/dist/client/session.d.ts +8 -25
  15. package/dist/client/session.d.ts.map +1 -1
  16. package/dist/client/session.js +56 -36
  17. package/dist/client/session.js.map +1 -1
  18. package/dist/client/sessionStore.d.ts +1 -1
  19. package/dist/client/sessionStore.js.map +1 -1
  20. package/dist/client/timing.d.ts +2 -1
  21. package/dist/client/timing.d.ts.map +1 -1
  22. package/dist/client/timing.js +5 -1
  23. package/dist/client/timing.js.map +1 -1
  24. package/dist/host/adapter.d.ts +8 -0
  25. package/dist/host/adapter.d.ts.map +1 -0
  26. package/dist/host/adapter.js +23 -0
  27. package/dist/host/adapter.js.map +1 -0
  28. package/dist/host/draft.d.ts +21 -7
  29. package/dist/host/draft.d.ts.map +1 -1
  30. package/dist/host/draft.js +190 -103
  31. package/dist/host/draft.js.map +1 -1
  32. package/dist/host/host.d.ts +18 -35
  33. package/dist/host/host.d.ts.map +1 -1
  34. package/dist/host/host.js +81 -53
  35. package/dist/host/host.js.map +1 -1
  36. package/dist/host/socket.d.ts +14 -0
  37. package/dist/host/socket.d.ts.map +1 -0
  38. package/dist/host/socket.js +459 -0
  39. package/dist/host/socket.js.map +1 -0
  40. package/dist/host-internal.d.ts +27 -0
  41. package/dist/host-internal.d.ts.map +1 -0
  42. package/dist/host-internal.js +3 -0
  43. package/dist/host.d.ts +44 -0
  44. package/dist/host.d.ts.map +1 -0
  45. package/dist/host.js +4 -0
  46. package/dist/index.d.ts +5 -28
  47. package/dist/index.js +2 -3
  48. package/dist/statewire.d.ts +94 -10
  49. package/dist/statewire.d.ts.map +1 -1
  50. package/dist/statewire.js +23 -35
  51. package/dist/statewire.js.map +1 -1
  52. package/dist/transports/fake-sse.d.ts +12 -0
  53. package/dist/transports/fake-sse.d.ts.map +1 -0
  54. package/dist/transports/fake-sse.js +39 -0
  55. package/dist/transports/fake-sse.js.map +1 -0
  56. package/dist/transports/fixtures.d.ts +33 -0
  57. package/dist/transports/fixtures.d.ts.map +1 -0
  58. package/dist/transports/fixtures.js +50 -0
  59. package/dist/transports/fixtures.js.map +1 -0
  60. package/dist/transports/remote.d.ts +6 -13
  61. package/dist/transports/remote.d.ts.map +1 -1
  62. package/dist/transports/remote.js +5 -24
  63. package/dist/transports/remote.js.map +1 -1
  64. package/dist/transports/sse.d.ts +10 -20
  65. package/dist/transports/sse.d.ts.map +1 -1
  66. package/dist/transports/sse.js +42 -41
  67. package/dist/transports/sse.js.map +1 -1
  68. package/dist/transports/testing.d.ts +4 -3
  69. package/dist/transports/testing.d.ts.map +1 -1
  70. package/dist/transports/testing.js +19 -46
  71. package/dist/transports/testing.js.map +1 -1
  72. package/dist/transports/ws.d.ts +4 -13
  73. package/dist/transports/ws.d.ts.map +1 -1
  74. package/dist/transports/ws.js +57 -60
  75. package/dist/transports/ws.js.map +1 -1
  76. package/dist/types.d.ts +53 -100
  77. package/dist/types.d.ts.map +1 -1
  78. package/dist/types.js.map +1 -1
  79. package/dist/util/draftTraps.d.ts +6 -0
  80. package/dist/util/draftTraps.d.ts.map +1 -0
  81. package/dist/util/draftTraps.js +25 -0
  82. package/dist/util/draftTraps.js.map +1 -0
  83. package/dist/util/macrotask.d.ts +5 -0
  84. package/dist/util/macrotask.d.ts.map +1 -0
  85. package/dist/util/macrotask.js +25 -0
  86. package/dist/util/macrotask.js.map +1 -0
  87. package/dist/util/methodProxy.d.ts +5 -0
  88. package/dist/util/methodProxy.d.ts.map +1 -0
  89. package/dist/util/methodProxy.js +17 -0
  90. package/dist/util/methodProxy.js.map +1 -0
  91. package/dist/util/randomId.d.ts +5 -0
  92. package/dist/util/randomId.d.ts.map +1 -0
  93. package/dist/util/randomId.js +6 -0
  94. package/dist/util/randomId.js.map +1 -0
  95. package/dist/{client/optimistic.d.ts → util/recordMutations.d.ts} +2 -2
  96. package/dist/util/recordMutations.d.ts.map +1 -0
  97. package/dist/{client/optimistic.js → util/recordMutations.js} +5 -24
  98. package/dist/util/recordMutations.js.map +1 -0
  99. package/dist/util/statePath.d.ts +14 -0
  100. package/dist/util/statePath.d.ts.map +1 -0
  101. package/dist/util/statePath.js +31 -0
  102. package/dist/util/statePath.js.map +1 -0
  103. package/dist/wire/meta.d.ts +6 -1
  104. package/dist/wire/meta.d.ts.map +1 -1
  105. package/dist/wire/meta.js +30 -28
  106. package/dist/wire/meta.js.map +1 -1
  107. package/dist/wire/ops.d.ts +5 -26
  108. package/dist/wire/ops.d.ts.map +1 -1
  109. package/dist/wire/ops.js +67 -80
  110. package/dist/wire/ops.js.map +1 -1
  111. package/package.json +15 -11
  112. package/src/client/changes.ts +5 -15
  113. package/src/client/connection.ts +87 -113
  114. package/src/client/core.ts +57 -91
  115. package/src/client/session.ts +87 -70
  116. package/src/client/sessionStore.ts +1 -1
  117. package/src/client/timing.ts +6 -0
  118. package/src/host/adapter.ts +33 -0
  119. package/src/host/draft.ts +293 -125
  120. package/src/host/host.ts +107 -106
  121. package/src/host/socket.ts +542 -0
  122. package/src/host-internal.ts +28 -0
  123. package/src/host.ts +61 -0
  124. package/src/index.ts +2 -62
  125. package/src/statewire.ts +154 -61
  126. package/src/transports/fake-sse.ts +51 -0
  127. package/src/transports/fixtures.ts +60 -0
  128. package/src/transports/remote.ts +12 -54
  129. package/src/transports/sse.ts +68 -81
  130. package/src/transports/testing.ts +27 -59
  131. package/src/transports/ws.ts +76 -92
  132. package/src/types.ts +50 -131
  133. package/src/util/draftTraps.ts +32 -0
  134. package/src/util/macrotask.ts +25 -0
  135. package/src/util/methodProxy.ts +16 -0
  136. package/src/util/randomId.ts +4 -0
  137. package/src/{client/optimistic.ts → util/recordMutations.ts} +0 -0
  138. package/src/util/statePath.ts +61 -0
  139. package/src/wire/meta.ts +42 -47
  140. package/src/wire/ops.ts +107 -106
  141. package/LICENSE +0 -21
  142. package/dist/client/optimistic.d.ts.map +0 -1
  143. package/dist/client/optimistic.js.map +0 -1
  144. package/dist/index.d.ts.map +0 -1
  145. package/dist/server.d.ts +0 -15
  146. package/dist/server.d.ts.map +0 -1
  147. package/dist/server.js +0 -3
  148. package/src/server.ts +0 -32
@@ -1,27 +1,28 @@
1
+ import { useEffect, useState } from "react";
1
2
  import { applyOps } from "../wire/ops";
2
3
  import type { StatewirePath } from "../wire/ops";
3
4
  import type { StatewireResponse } from "./connection";
4
5
  import { bindCommandsHost } from "../host/host";
5
6
  import type { CommandsInvocation, CommandsListener } from "../host/host";
6
- import type { StatewireStateSource } from "../types";
7
+ import type { Statewire } from "../types";
8
+ import type { StatewireClient } from "../statewire";
7
9
  import { createMemorySessionStore } from "./sessionStore";
8
10
  import type {
9
11
  StatewireSessionRecord,
10
12
  StatewireSessionStore,
13
+ StatewireSessionStoreOptions,
11
14
  } from "./sessionStore";
12
- import { backoff } from "./timing";
15
+ import { retryDelay } from "./timing";
16
+ import { createMethodProxy } from "../util/methodProxy";
17
+ import { randomId } from "../util/randomId";
13
18
  import { StatewireSendError } from "../types";
14
- import type { SendFailure as StatewireSendFailure } from "../types";
15
19
  import type {
16
20
  RemoteLink,
21
+ StatewireRemoteProps,
17
22
  StatewireSubmitResult,
18
23
  StatewireWireCommand,
19
24
  } from "../transports/sse";
20
25
 
21
- export type SessionOptions = {
22
- sessionStore?: StatewireSessionStore | undefined;
23
- };
24
-
25
26
  /**
26
27
  * queued/trying/posted: overlay live, result pending. awaiting-result: acked
27
28
  * (overlay gone) but the result rides a late res; `posted: false` re-sends.
@@ -47,7 +48,7 @@ type Pending = {
47
48
 
48
49
  type Outcome =
49
50
  | { ok: true; payload: unknown }
50
- | { ok: false; failure: StatewireSendFailure };
51
+ | { ok: false; failure: StatewireClient.SendFailure };
51
52
 
52
53
  const isResolved = (state: PendingState) =>
53
54
  state.at === "settled-live" || state.at === "done";
@@ -55,6 +56,15 @@ const isResolved = (state: PendingState) =>
55
56
  const isStateSettled = (state: PendingState) =>
56
57
  state.at === "awaiting-result" || state.at === "done";
57
58
 
59
+ // The pump admits an entry as trying before sending; only the pump itself
60
+ // requeues, and a done entry is never still pending.
61
+ const inFlight = (state: PendingState) => {
62
+ if (state.at === "queued" || state.at === "done") {
63
+ throw new Error(`statewire: ${state.at} entry observed after a send`);
64
+ }
65
+ return state;
66
+ };
67
+
58
68
  const legalTransitions: Record<PendingState["at"], PendingState["at"][]> = {
59
69
  queued: ["queued", "trying", "awaiting-result", "settled-live", "done"],
60
70
  // trying -> queued: an answered unknown-client proves the seq was never admitted.
@@ -122,10 +132,11 @@ const carried = (
122
132
  ? { posted: state.at === "posted", failedAttempts: state.failedAttempts }
123
133
  : { posted: state.posted, failedAttempts: state.failedAttempts };
124
134
 
125
- const newClientId = () =>
126
- `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
135
+ const newClientId = () => `c-${randomId()}`;
127
136
 
128
- const rejectedSend = (failure: StatewireSendFailure): Promise<unknown> => {
137
+ const rejectedSend = (
138
+ failure: StatewireClient.SendFailure,
139
+ ): Promise<unknown> => {
129
140
  const result = Promise.reject<unknown>(new StatewireSendError(failure));
130
141
  result.catch(() => {});
131
142
  return result;
@@ -135,6 +146,7 @@ type AttachState = "detached" | "attached" | "finished";
135
146
 
136
147
  type Session = {
137
148
  record: StatewireSessionRecord;
149
+ ctx: StatewireRemoteProps;
138
150
  recordPersisted: boolean;
139
151
  store: StatewireSessionStore;
140
152
  lastAck: number;
@@ -142,7 +154,7 @@ type Session = {
142
154
  canonical: unknown;
143
155
  lastPublished: unknown;
144
156
  changed: StatewirePath[];
145
- source: StatewireStateSource;
157
+ source: Statewire.StateSource;
146
158
  attach: AttachState;
147
159
  attachEpoch: number;
148
160
  running: boolean;
@@ -156,29 +168,20 @@ type Session = {
156
168
  commands: Record<string, (...params: unknown[]) => Promise<unknown>>;
157
169
  };
158
170
 
159
- const createCommandsProxy = (
160
- send: (command: StatewireWireCommand) => Promise<unknown>,
161
- ): Session["commands"] => {
162
- const memo = new Map<string, (...params: unknown[]) => Promise<unknown>>();
163
- return new Proxy({} as Session["commands"], {
164
- get: (_target, method) => {
165
- if (typeof method !== "string") return undefined;
166
- let fn = memo.get(method);
167
- if (fn === undefined) {
168
- fn = (...params: unknown[]) => send({ method, params });
169
- memo.set(method, fn);
170
- }
171
- return fn;
172
- },
173
- });
174
- };
175
-
176
- const createSession = (options: SessionOptions): Session => {
171
+ const createSession = (options: StatewireSessionStoreOptions): Session => {
177
172
  const store = options.sessionStore ?? createMemorySessionStore();
178
173
  const stored = store.load();
179
174
  const record = stored ?? { clientId: newClientId(), nextSeq: 1 };
180
175
  const s: Session = {
181
176
  record,
177
+ ctx: {
178
+ get clientId() {
179
+ return s.record.clientId;
180
+ },
181
+ onSnapshot: (args) => handleSnapshot(s, args),
182
+ onOps: (args) => handleOps(s, args),
183
+ onFinish: (args) => handleFinish(s, args),
184
+ },
182
185
  recordPersisted: stored !== null,
183
186
  store,
184
187
  lastAck: -1,
@@ -187,7 +190,7 @@ const createSession = (options: SessionOptions): Session => {
187
190
  lastPublished: undefined,
188
191
  changed: [],
189
192
  source: {
190
- root: () => s.canonical,
193
+ snapshot: () => s.canonical,
191
194
  takeChanged: () => {
192
195
  const changed = s.changed;
193
196
  s.changed = [];
@@ -207,7 +210,7 @@ const createSession = (options: SessionOptions): Session => {
207
210
  notify: () => {},
208
211
  emitEvent: () => {},
209
212
  send: (command) => sendCommand(s, command),
210
- commands: createCommandsProxy((command) => s.send(command)),
213
+ commands: createMethodProxy((method, params) => s.send({ method, params })),
211
214
  };
212
215
  bindCommandsHost(s.commands, {
213
216
  setListener: (listener) => {
@@ -231,12 +234,25 @@ const wake = (s: Session) => {
231
234
 
232
235
  const waitForWake = (s: Session) => new Promise<void>((r) => s.waiters.push(r));
233
236
 
237
+ const sleepUntilWake = (s: Session, delayMs: number) =>
238
+ new Promise<void>((resolve) => {
239
+ const waiter = () => {
240
+ clearTimeout(timer);
241
+ resolve();
242
+ };
243
+ const timer = setTimeout(() => {
244
+ s.waiters = s.waiters.filter((w) => w !== waiter);
245
+ resolve();
246
+ }, delayMs);
247
+ s.waiters.push(waiter);
248
+ });
249
+
234
250
  const settleAll = (
235
251
  s: Session,
236
252
  detach: "evicted" | "unmount" | "gone" | "superseded",
237
253
  verdict?: {
238
254
  entry: Pending;
239
- failure: StatewireSendFailure;
255
+ failure: StatewireClient.SendFailure;
240
256
  },
241
257
  ) => {
242
258
  const cause = (
@@ -248,7 +264,7 @@ const settleAll = (
248
264
  } as const
249
265
  )[detach];
250
266
  const batch = s.pending;
251
- const detachFailure = (entry: Pending): StatewireSendFailure => {
267
+ const detachFailure = (entry: Pending): StatewireClient.SendFailure => {
252
268
  const state = entry.state;
253
269
  if (state.at === "awaiting-result")
254
270
  return { fate: "result-unavailable", cause };
@@ -271,7 +287,7 @@ const evict = (
271
287
  s: Session,
272
288
  verdict?: {
273
289
  entry: Pending;
274
- failure: StatewireSendFailure;
290
+ failure: StatewireClient.SendFailure;
275
291
  },
276
292
  ) => {
277
293
  settleAll(s, "evicted", verdict);
@@ -347,12 +363,16 @@ const applyResponse = (s: Session, response: StatewireResponse) => {
347
363
  ? {
348
364
  fate: "invalid-params",
349
365
  cause: "server",
350
- message: response.message ?? "",
366
+ message:
367
+ response.message ??
368
+ protocolError("rejected response missing message"),
351
369
  }
352
370
  : {
353
371
  fate: "rejected",
354
372
  cause: "server",
355
- message: response.message ?? "",
373
+ message:
374
+ response.message ??
375
+ protocolError("rejected response missing message"),
356
376
  ...(response.payload !== undefined && {
357
377
  payload: response.payload,
358
378
  }),
@@ -382,12 +402,9 @@ const ingestOps = (s: Session, ops: unknown[]) => {
382
402
  return applied.events;
383
403
  };
384
404
 
385
- const publishIngested = (
386
- s: Session,
387
- events: { event: unknown; state: unknown }[],
388
- ) => {
405
+ const publishIngested = (s: Session, events: unknown[]) => {
389
406
  publish(s);
390
- for (const { event } of events) s.emitEvent(event);
407
+ for (const event of events) s.emitEvent(event);
391
408
  wake(s);
392
409
  };
393
410
 
@@ -459,7 +476,7 @@ const handleOps = (
459
476
  };
460
477
 
461
478
  const handleFinish = (s: Session, { reason }: { reason: string }) => {
462
- if (reason === "ended" || reason === "error") return;
479
+ if (reason === "evicted" || reason === "error") return;
463
480
  s.attach = "finished";
464
481
  settleAll(s, reason === "superseded" ? "superseded" : "gone");
465
482
  publish(s);
@@ -504,18 +521,12 @@ const pump = async (s: Session) => {
504
521
  } catch (error) {
505
522
  if (!s.running || isFinished(s)) return;
506
523
  if (!s.pending.includes(entry)) continue;
507
- const state = entry.state;
508
- if (state.at === "queued" || state.at === "done") continue;
524
+ const state = inFlight(entry.state);
509
525
  transition(s, entry, {
510
526
  ...state,
511
527
  failedAttempts: state.failedAttempts + 1,
512
528
  });
513
- const { retryAfterMs } = error as { retryAfterMs?: unknown };
514
- const delay =
515
- typeof retryAfterMs === "number"
516
- ? retryAfterMs
517
- : backoff(state.failedAttempts + 1);
518
- await new Promise((r) => setTimeout(r, delay));
529
+ await sleepUntilWake(s, retryDelay(error, state.failedAttempts + 1));
519
530
  continue;
520
531
  }
521
532
  if (!s.running) return;
@@ -541,7 +552,6 @@ const pump = async (s: Session) => {
541
552
  publish(s);
542
553
  }
543
554
  } else if (result.type === "rejected") {
544
- const { message, payload } = result;
545
555
  transition(
546
556
  s,
547
557
  entry,
@@ -551,8 +561,7 @@ const pump = async (s: Session) => {
551
561
  failure: {
552
562
  fate: "rejected",
553
563
  cause: "server",
554
- message,
555
- ...(payload !== undefined && { payload }),
564
+ message: result.message,
556
565
  },
557
566
  },
558
567
  );
@@ -589,16 +598,14 @@ const pump = async (s: Session) => {
589
598
  );
590
599
  // Every attempt answered means the server never saw it; a thrown
591
600
  // attempt may have left the machine unanswered, so the verdict is "lost".
592
- const state = entry.state;
593
- const failure: StatewireSendFailure =
601
+ const state = inFlight(entry.state);
602
+ const failure: StatewireClient.SendFailure =
594
603
  state.at === "awaiting-result"
595
604
  ? { fate: "result-unavailable", cause: "session-lost" }
596
- : state.at === "queued" || state.at === "done"
597
- ? { fate: "aborted", cause: "session-lost" }
598
- : {
599
- fate: state.failedAttempts > 0 ? "lost" : "aborted",
600
- cause: "session-lost",
601
- };
605
+ : {
606
+ fate: state.failedAttempts > 0 ? "lost" : "aborted",
607
+ cause: "session-lost",
608
+ };
602
609
  evict(s, { entry, failure });
603
610
  s.transport?.connection.reconnect();
604
611
  }
@@ -668,12 +675,22 @@ const sendCommand = (
668
675
  return result;
669
676
  };
670
677
 
671
- export {
672
- createSession,
673
- handleSnapshot,
674
- handleFinish,
675
- handleOps,
676
- setTransport,
677
- start,
678
- stop,
678
+ export const useStatewireSession = (
679
+ options: StatewireSessionStoreOptions,
680
+ emit: (event: unknown) => void,
681
+ ): Session => {
682
+ const [s] = useState(() => createSession(options));
683
+ useEffect(() => {
684
+ s.emitEvent = emit;
685
+ return () => {
686
+ s.emitEvent = () => {};
687
+ };
688
+ }, [s, emit]);
689
+ useEffect(() => {
690
+ start(s);
691
+ return () => stop(s);
692
+ }, [s]);
693
+ return s;
679
694
  };
695
+
696
+ export { setTransport };
@@ -9,7 +9,7 @@ export type StatewireSessionStore = {
9
9
  };
10
10
 
11
11
  export type StatewireSessionStoreOptions = {
12
- sessionStore?: StatewireSessionStore;
12
+ sessionStore?: StatewireSessionStore | undefined;
13
13
  };
14
14
 
15
15
  export const createMemorySessionStore = (): StatewireSessionStore => {
@@ -6,3 +6,9 @@ export const timing = {
6
6
 
7
7
  export const backoff = (attempt: number) =>
8
8
  Math.min(timing.backoffBaseMs * 2 ** (attempt - 1), timing.backoffMaxMs);
9
+
10
+ export const retryDelay = (error: unknown, attempt: number): number => {
11
+ const retryAfterMs = (error as { retryAfterMs?: unknown } | undefined)
12
+ ?.retryAfterMs;
13
+ return typeof retryAfterMs === "number" ? retryAfterMs : backoff(attempt);
14
+ };
@@ -0,0 +1,33 @@
1
+ import type { StatewireHostInternal } from "../host-internal";
2
+
3
+ export const CLIENT_QUERY_PARAM = "client";
4
+
5
+ export const clientIdFromUrl = (url: string | null | undefined): string => {
6
+ if (!url) {
7
+ throw new Error("statewire: cannot resolve client id: missing url");
8
+ }
9
+ let parsed: URL;
10
+ try {
11
+ parsed = new URL(url, "http://statewire");
12
+ } catch {
13
+ throw new Error(
14
+ `statewire: cannot resolve client id: unparseable url "${url}"`,
15
+ );
16
+ }
17
+ const clientId = parsed.searchParams.get(CLIENT_QUERY_PARAM);
18
+ if (!clientId) {
19
+ throw new Error(
20
+ `statewire: url is missing the "${CLIENT_QUERY_PARAM}" query parameter`,
21
+ );
22
+ }
23
+ return clientId;
24
+ };
25
+
26
+ export const textFrameOrClose = (
27
+ socket: Pick<StatewireHostInternal.Socket, "close">,
28
+ data: unknown,
29
+ ): string | null => {
30
+ if (typeof data === "string") return data;
31
+ socket.close(1008, "text frames only");
32
+ return null;
33
+ };