statewire 0.0.0-0 → 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 (154) hide show
  1. package/README.md +84 -1
  2. package/dist/client/changes.d.ts +7 -0
  3. package/dist/client/changes.d.ts.map +1 -0
  4. package/dist/client/changes.js +25 -0
  5. package/dist/client/changes.js.map +1 -0
  6. package/dist/client/connection.d.ts +63 -0
  7. package/dist/client/connection.d.ts.map +1 -0
  8. package/dist/client/connection.js +211 -0
  9. package/dist/client/connection.js.map +1 -0
  10. package/dist/client/core.d.ts +14 -0
  11. package/dist/client/core.d.ts.map +1 -0
  12. package/dist/client/core.js +272 -0
  13. package/dist/client/core.js.map +1 -0
  14. package/dist/client/session.d.ts +71 -0
  15. package/dist/client/session.d.ts.map +1 -0
  16. package/dist/client/session.js +529 -0
  17. package/dist/client/session.js.map +1 -0
  18. package/dist/client/sessionStore.d.ts +16 -0
  19. package/dist/client/sessionStore.d.ts.map +1 -0
  20. package/dist/client/sessionStore.js +14 -0
  21. package/dist/client/sessionStore.js.map +1 -0
  22. package/dist/client/timing.d.ts +11 -0
  23. package/dist/client/timing.d.ts.map +1 -0
  24. package/dist/client/timing.js +15 -0
  25. package/dist/client/timing.js.map +1 -0
  26. package/dist/host/adapter.d.ts +8 -0
  27. package/dist/host/adapter.d.ts.map +1 -0
  28. package/dist/host/adapter.js +23 -0
  29. package/dist/host/adapter.js.map +1 -0
  30. package/dist/host/draft.d.ts +28 -0
  31. package/dist/host/draft.d.ts.map +1 -0
  32. package/dist/host/draft.js +211 -0
  33. package/dist/host/draft.js.map +1 -0
  34. package/dist/host/engine.d.ts +14 -0
  35. package/dist/host/engine.d.ts.map +1 -0
  36. package/dist/host/engine.js +17 -0
  37. package/dist/host/engine.js.map +1 -0
  38. package/dist/host/host.d.ts +62 -0
  39. package/dist/host/host.d.ts.map +1 -0
  40. package/dist/host/host.js +331 -0
  41. package/dist/host/host.js.map +1 -0
  42. package/dist/host/socket.d.ts +14 -0
  43. package/dist/host/socket.d.ts.map +1 -0
  44. package/dist/host/socket.js +459 -0
  45. package/dist/host/socket.js.map +1 -0
  46. package/dist/host-internal.d.ts +27 -0
  47. package/dist/host-internal.d.ts.map +1 -0
  48. package/dist/host-internal.js +3 -0
  49. package/dist/host.d.ts +44 -0
  50. package/dist/host.d.ts.map +1 -0
  51. package/dist/host.js +4 -0
  52. package/dist/index.d.ts +5 -0
  53. package/dist/index.js +5 -0
  54. package/dist/statewire.d.ts +104 -0
  55. package/dist/statewire.d.ts.map +1 -0
  56. package/dist/statewire.js +53 -0
  57. package/dist/statewire.js.map +1 -0
  58. package/dist/transports/fake-sse.d.ts +12 -0
  59. package/dist/transports/fake-sse.d.ts.map +1 -0
  60. package/dist/transports/fake-sse.js +39 -0
  61. package/dist/transports/fake-sse.js.map +1 -0
  62. package/dist/transports/fixtures.d.ts +33 -0
  63. package/dist/transports/fixtures.d.ts.map +1 -0
  64. package/dist/transports/fixtures.js +50 -0
  65. package/dist/transports/fixtures.js.map +1 -0
  66. package/dist/transports/remote.d.ts +12 -0
  67. package/dist/transports/remote.d.ts.map +1 -0
  68. package/dist/transports/remote.js +21 -0
  69. package/dist/transports/remote.js.map +1 -0
  70. package/dist/transports/sse-parser.d.ts +5 -0
  71. package/dist/transports/sse-parser.d.ts.map +1 -0
  72. package/dist/transports/sse-parser.js +26 -0
  73. package/dist/transports/sse-parser.js.map +1 -0
  74. package/dist/transports/sse.d.ts +64 -0
  75. package/dist/transports/sse.d.ts.map +1 -0
  76. package/dist/transports/sse.js +137 -0
  77. package/dist/transports/sse.js.map +1 -0
  78. package/dist/transports/testing.d.ts +32 -0
  79. package/dist/transports/testing.d.ts.map +1 -0
  80. package/dist/transports/testing.js +94 -0
  81. package/dist/transports/testing.js.map +1 -0
  82. package/dist/transports/ws.d.ts +9 -0
  83. package/dist/transports/ws.d.ts.map +1 -0
  84. package/dist/transports/ws.js +169 -0
  85. package/dist/transports/ws.js.map +1 -0
  86. package/dist/types.d.ts +60 -0
  87. package/dist/types.d.ts.map +1 -0
  88. package/dist/types.js +24 -0
  89. package/dist/types.js.map +1 -0
  90. package/dist/util/draftTraps.d.ts +6 -0
  91. package/dist/util/draftTraps.d.ts.map +1 -0
  92. package/dist/util/draftTraps.js +25 -0
  93. package/dist/util/draftTraps.js.map +1 -0
  94. package/dist/util/macrotask.d.ts +5 -0
  95. package/dist/util/macrotask.d.ts.map +1 -0
  96. package/dist/util/macrotask.js +25 -0
  97. package/dist/util/macrotask.js.map +1 -0
  98. package/dist/util/methodProxy.d.ts +5 -0
  99. package/dist/util/methodProxy.d.ts.map +1 -0
  100. package/dist/util/methodProxy.js +17 -0
  101. package/dist/util/methodProxy.js.map +1 -0
  102. package/dist/util/randomId.d.ts +5 -0
  103. package/dist/util/randomId.d.ts.map +1 -0
  104. package/dist/util/randomId.js +6 -0
  105. package/dist/util/randomId.js.map +1 -0
  106. package/dist/util/recordMutations.d.ts +10 -0
  107. package/dist/util/recordMutations.d.ts.map +1 -0
  108. package/dist/util/recordMutations.js +109 -0
  109. package/dist/util/recordMutations.js.map +1 -0
  110. package/dist/util/statePath.d.ts +14 -0
  111. package/dist/util/statePath.d.ts.map +1 -0
  112. package/dist/util/statePath.js +31 -0
  113. package/dist/util/statePath.js.map +1 -0
  114. package/dist/wire/meta.d.ts +27 -0
  115. package/dist/wire/meta.d.ts.map +1 -0
  116. package/dist/wire/meta.js +75 -0
  117. package/dist/wire/meta.js.map +1 -0
  118. package/dist/wire/ops.d.ts +19 -0
  119. package/dist/wire/ops.d.ts.map +1 -0
  120. package/dist/wire/ops.js +112 -0
  121. package/dist/wire/ops.js.map +1 -0
  122. package/package.json +52 -5
  123. package/src/client/changes.ts +38 -0
  124. package/src/client/connection.ts +301 -0
  125. package/src/client/core.ts +312 -0
  126. package/src/client/session.ts +696 -0
  127. package/src/client/sessionStore.ts +23 -0
  128. package/src/client/timing.ts +14 -0
  129. package/src/host/adapter.ts +33 -0
  130. package/src/host/draft.ts +323 -0
  131. package/src/host/engine.ts +13 -0
  132. package/src/host/host.ts +482 -0
  133. package/src/host/socket.ts +542 -0
  134. package/src/host-internal.ts +28 -0
  135. package/src/host.ts +61 -0
  136. package/src/index.ts +5 -0
  137. package/src/statewire.ts +200 -0
  138. package/src/transports/fake-sse.ts +51 -0
  139. package/src/transports/fixtures.ts +60 -0
  140. package/src/transports/remote.ts +30 -0
  141. package/src/transports/sse-parser.ts +24 -0
  142. package/src/transports/sse.ts +267 -0
  143. package/src/transports/testing.ts +123 -0
  144. package/src/transports/ws.ts +256 -0
  145. package/src/types.ts +88 -0
  146. package/src/util/draftTraps.ts +32 -0
  147. package/src/util/macrotask.ts +25 -0
  148. package/src/util/methodProxy.ts +16 -0
  149. package/src/util/randomId.ts +4 -0
  150. package/src/util/recordMutations.ts +0 -0
  151. package/src/util/statePath.ts +61 -0
  152. package/src/wire/meta.ts +101 -0
  153. package/src/wire/ops.ts +202 -0
  154. package/index.js +0 -1
@@ -0,0 +1,301 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { flushTapSync } from "@assistant-ui/tap";
3
+ import { reviveOps, reviveResponse, reviveValue } from "../wire/meta";
4
+ import { retryDelay } from "./timing";
5
+ import type { ConnectionState, StatewireRemoteProps } from "../transports/sse";
6
+ import type { StatewireClient } from "../statewire";
7
+ import type { StatewireHost } from "../host";
8
+
9
+ export type Episode = {
10
+ cause: "dropped" | "evicted" | "error";
11
+ message?: string;
12
+ };
13
+
14
+ export type StatewireResponse = {
15
+ seq: number;
16
+ type: "accepted" | "rejected" | "pending" | "crashed" | "result-unavailable";
17
+ code?: string;
18
+ message?: string;
19
+ payload?: unknown;
20
+ meta?: unknown;
21
+ };
22
+
23
+ export type StatewireSyn = {
24
+ lastSeq: number;
25
+ lease?: string;
26
+ };
27
+
28
+ export type StatewireEnvelope = {
29
+ ops?: unknown[];
30
+ res?: StatewireResponse[];
31
+ ack?: number;
32
+ syn?: StatewireSyn;
33
+ fin?: StatewireHost.Fin;
34
+ };
35
+
36
+ const createGate = () => {
37
+ let latched = false;
38
+ let wake: (() => void) | undefined;
39
+ return {
40
+ take: () => {
41
+ const was = latched;
42
+ latched = false;
43
+ return was;
44
+ },
45
+ wait: (timeoutMs?: number) => {
46
+ if (latched) {
47
+ latched = false;
48
+ return Promise.resolve();
49
+ }
50
+ return new Promise<void>((resolve) => {
51
+ const timer =
52
+ timeoutMs === undefined
53
+ ? undefined
54
+ : setTimeout(() => {
55
+ wake = undefined;
56
+ resolve();
57
+ }, timeoutMs);
58
+ wake = () => {
59
+ clearTimeout(timer);
60
+ wake = undefined;
61
+ resolve();
62
+ };
63
+ });
64
+ },
65
+ open: () => {
66
+ if (wake) wake();
67
+ else latched = true;
68
+ },
69
+ };
70
+ };
71
+
72
+ export type Gate = ReturnType<typeof createGate>;
73
+
74
+ export type Conn = {
75
+ connection: StatewireClient.Connection;
76
+ hasSnapshot: boolean;
77
+ lease: string | null;
78
+ attempt: number;
79
+ episode: Episode | null;
80
+ gate: Gate;
81
+ abort: AbortController | null;
82
+ lifetime: AbortController | null;
83
+ ctx: StatewireRemoteProps;
84
+ onChange: (connection: StatewireClient.Connection) => void;
85
+ attemptOnce: (
86
+ abort: AbortController,
87
+ ) => Promise<"stop" | "reattach" | "ended">;
88
+ notify: (connection: StatewireClient.Connection) => void;
89
+ reconnect: () => void;
90
+ };
91
+
92
+ const setStatus = (c: Conn, next: ConnectionState) => {
93
+ const changed = next.status !== c.connection.status;
94
+ c.connection = { ...next, reconnect: c.reconnect };
95
+ c.notify(c.connection);
96
+ if (changed) c.onChange(c.connection);
97
+ };
98
+
99
+ const deliverEnvelope = (
100
+ c: Conn,
101
+ env: Pick<StatewireEnvelope, "ops" | "res" | "ack" | "syn">,
102
+ ) => {
103
+ if (env.syn?.lease !== undefined) c.lease = env.syn.lease;
104
+ const ctx = c.ctx;
105
+ const ops = env.ops === undefined ? [] : reviveOps(env.ops);
106
+ const res = env.res?.map(reviveResponse);
107
+ if (env.syn !== undefined) {
108
+ ctx.onSnapshot({ ops, lastSeq: env.syn.lastSeq });
109
+ c.hasSnapshot = true;
110
+ } else if (
111
+ env.ops !== undefined ||
112
+ env.res !== undefined ||
113
+ env.ack !== undefined
114
+ ) {
115
+ ctx.onOps({
116
+ ops,
117
+ ...(res !== undefined && { res }),
118
+ ...(env.ack !== undefined && { ack: env.ack }),
119
+ });
120
+ }
121
+ };
122
+
123
+ const isTransientFinish = (
124
+ reason: StatewireHost.Fin["reason"],
125
+ ): reason is "evicted" | "error" => reason === "evicted" || reason === "error";
126
+
127
+ const settleEnvelope = (
128
+ c: Conn,
129
+ env: StatewireEnvelope,
130
+ ): "stop" | "reattach" | null => {
131
+ let finish = env.fin;
132
+ if (finish) {
133
+ if (finish.meta !== undefined) {
134
+ const { meta, ...rest } = finish;
135
+ finish = { ...rest, payload: reviveValue(rest.payload, meta) };
136
+ }
137
+ c.ctx.onFinish(finish);
138
+ if (isTransientFinish(finish.reason)) {
139
+ c.episode = {
140
+ cause: finish.reason,
141
+ ...(finish.message !== undefined && { message: finish.message }),
142
+ };
143
+ return "reattach";
144
+ }
145
+ setStatus(c, {
146
+ status: "gone",
147
+ ...(finish.message !== undefined && { message: finish.message }),
148
+ ...(finish.payload !== undefined && { payload: finish.payload }),
149
+ });
150
+ return "stop";
151
+ }
152
+ if (c.connection.status !== "live") {
153
+ c.attempt = 0;
154
+ c.episode = null;
155
+ setStatus(c, { status: "live" });
156
+ }
157
+ return null;
158
+ };
159
+
160
+ export const handleEnvelope = (
161
+ c: Conn,
162
+ env: StatewireEnvelope,
163
+ ): "stop" | "reattach" | null => {
164
+ deliverEnvelope(c, env);
165
+ return settleEnvelope(c, env);
166
+ };
167
+
168
+ const scheduleRetry = (
169
+ c: Conn,
170
+ failure: string | undefined,
171
+ immediate: boolean,
172
+ cause?: unknown,
173
+ ) => {
174
+ c.attempt += 1;
175
+ c.episode ??= { cause: "dropped" };
176
+ const delay = immediate ? 0 : retryDelay(cause, c.attempt);
177
+ if (c.hasSnapshot) {
178
+ setStatus(c, {
179
+ status: "reconnecting",
180
+ ...c.episode,
181
+ attempt: c.attempt,
182
+ nextRetryAt: Date.now() + delay,
183
+ ...(failure !== undefined && { lastError: failure }),
184
+ });
185
+ }
186
+ return delay;
187
+ };
188
+
189
+ const reconnectConn = (c: Conn) => {
190
+ if (!c.lifetime) return;
191
+ if (c.connection.status === "gone") {
192
+ c.attempt = 0;
193
+ c.episode = { cause: "dropped" };
194
+ flushTapSync(() => {
195
+ if (c.hasSnapshot) scheduleRetry(c, undefined, true);
196
+ else setStatus(c, { status: "connecting" });
197
+ });
198
+ }
199
+ c.gate.open();
200
+ c.abort?.abort();
201
+ };
202
+
203
+ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
204
+ const alive = () => c.lifetime === lifetime;
205
+ while (alive()) {
206
+ const abort = new AbortController();
207
+ c.abort = abort;
208
+ let failure: string | undefined;
209
+ let cause: unknown;
210
+ let gone = false;
211
+ try {
212
+ gone = (await c.attemptOnce(abort)) === "stop";
213
+ } catch (error) {
214
+ if (!abort.signal.aborted) {
215
+ failure =
216
+ error instanceof Error && error.message ? error.message : "network";
217
+ cause = error;
218
+ }
219
+ }
220
+ abort.abort();
221
+ if (!alive()) return;
222
+ if (gone) {
223
+ await gate.wait();
224
+ continue;
225
+ }
226
+ const immediate = gate.take();
227
+ const delay = flushTapSync(() =>
228
+ scheduleRetry(c, failure, immediate, cause),
229
+ );
230
+ if (delay === 0) continue;
231
+ await gate.wait(delay);
232
+ }
233
+ };
234
+
235
+ export const useStatewireConn = (props: {
236
+ url: string;
237
+ onChange?: ((connection: StatewireClient.Connection) => void) | undefined;
238
+ ctx: StatewireRemoteProps;
239
+ attemptOnce: (
240
+ c: Conn,
241
+ abort: AbortController,
242
+ ) => Promise<"stop" | "reattach" | "ended">;
243
+ }): Conn => {
244
+ const latest = useRef(props);
245
+ latest.current = props;
246
+ const [, setConnection] = useState<StatewireClient.Connection | null>(null);
247
+ const [c] = useState((): Conn => {
248
+ const conn: Conn = {
249
+ hasSnapshot: false,
250
+ lease: null,
251
+ attempt: 0,
252
+ episode: null,
253
+ abort: null,
254
+ gate: createGate(),
255
+ lifetime: null,
256
+ ctx: props.ctx,
257
+ onChange: (connection) => latest.current.onChange?.(connection),
258
+ attemptOnce: (abort) => latest.current.attemptOnce(conn, abort),
259
+ notify: setConnection,
260
+ reconnect: () => reconnectConn(conn),
261
+ connection: { status: "connecting", reconnect: () => conn.reconnect() },
262
+ };
263
+ return conn;
264
+ });
265
+
266
+ useEffect(() => {
267
+ c.hasSnapshot = false;
268
+ c.lease = null;
269
+ c.attempt = 0;
270
+ c.episode = null;
271
+ if (c.connection.status !== "connecting") {
272
+ setStatus(c, { status: "connecting" });
273
+ }
274
+ const lifetime = new AbortController();
275
+ c.lifetime = lifetime;
276
+ c.gate = createGate();
277
+ void run(c, lifetime, c.gate);
278
+ return () => {
279
+ c.abort?.abort();
280
+ lifetime.abort();
281
+ if (c.lifetime === lifetime) c.lifetime = null;
282
+ c.gate.open();
283
+ };
284
+ }, [c, props.url]);
285
+
286
+ return c;
287
+ };
288
+
289
+ export const classify = (timeout: AbortSignal) =>
290
+ new Error(timeout.aborted ? "timeout" : "network");
291
+
292
+ export const parseRetryAfter = (header: string | null): number | undefined => {
293
+ if (!header) return undefined;
294
+ const value = header.trim();
295
+ if (/^\d+$/.test(value)) return Number(value) * 1000;
296
+ const date = Date.parse(value);
297
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
298
+ };
299
+
300
+ export const overloadedError = (retryAfterMs?: number) =>
301
+ Object.assign(new Error("overloaded"), { retryAfterMs });
@@ -0,0 +1,312 @@
1
+ import { useEffect, useState } from "react";
2
+ import { createChanges } from "./changes";
3
+ import { getCommandsHost, getEmitHost, getStateHost } from "../host/host";
4
+ import type { CommandsInvocation, CommandsListener } from "../host/host";
5
+ import { recordMutations } from "../util/recordMutations";
6
+ import { createMacrotaskPoster } from "../util/macrotask";
7
+ import { createMethodProxy } from "../util/methodProxy";
8
+ import type { StatewirePath } from "../wire/ops";
9
+ import { StatewireSendError } from "../types";
10
+ import type { Statewire } from "../types";
11
+ import type { StatewireClient } from "../statewire";
12
+
13
+ export const LIVE_CONNECTION: StatewireClient.Connection = {
14
+ status: "live",
15
+ reconnect: () => {},
16
+ };
17
+
18
+ const failureOf = (error: unknown): StatewireClient.SendFailure =>
19
+ error instanceof StatewireSendError
20
+ ? error.failure
21
+ : {
22
+ fate: "rejected",
23
+ cause: "server",
24
+ message: error instanceof Error ? error.message : String(error),
25
+ };
26
+
27
+ type Overlay = { inv: CommandsInvocation; dropped: boolean };
28
+
29
+ const createCore = (
30
+ getOptions: () => StatewireClient.Options<any, any, any>,
31
+ ) => {
32
+ let value: Statewire<unknown> | null = null;
33
+ let source: Statewire.StateSource | null = null;
34
+ let overlays: Overlay[] = [];
35
+ let updates: Statewire.CommandUpdate[] = [];
36
+ let events: unknown[] = [];
37
+ let pendingChanged: StatewirePath[] = [];
38
+ let canonical: unknown;
39
+ let published: unknown;
40
+ let overlayPaths: readonly StatewirePath[] = [];
41
+ let notifiedPublished: unknown;
42
+ let notifiedOverlayPaths: readonly StatewirePath[] = [];
43
+ let notifiedConnection: StatewireClient.Connection | null = null;
44
+ let stale = false;
45
+ let overlaysDirty = false;
46
+ let flushing = false;
47
+ const listeners = new Set<() => void>();
48
+
49
+ const post = createMacrotaskPoster(() => {
50
+ scheduled = false;
51
+ flush();
52
+ });
53
+ let scheduled = false;
54
+ const schedule = () => {
55
+ if (scheduled) return;
56
+ scheduled = true;
57
+ post();
58
+ };
59
+
60
+ const markStale = () => {
61
+ stale = true;
62
+ schedule();
63
+ };
64
+
65
+ const drainCanonical = () => {
66
+ if (source) {
67
+ pendingChanged.push(...source.takeChanged());
68
+ canonical = source.snapshot();
69
+ } else if (value && value.state !== canonical) {
70
+ pendingChanged.push([]);
71
+ canonical = value.state;
72
+ }
73
+ };
74
+
75
+ const recompute = () => {
76
+ if (!stale || !value) return;
77
+ stale = false;
78
+ const prevCanonical = canonical;
79
+ drainCanonical();
80
+ if (canonical === prevCanonical && !overlaysDirty) return;
81
+ overlaysDirty = false;
82
+ const { optimistic } = getOptions();
83
+ let next = canonical;
84
+ let paths: readonly StatewirePath[] = [];
85
+ if (optimistic && canonical !== undefined) {
86
+ for (const overlay of overlays) {
87
+ if (overlay.dropped) continue;
88
+ const { inv } = overlay;
89
+ const ctx: StatewireClient.OptimisticContext = {
90
+ ts: inv.ts,
91
+ seq: inv.seq,
92
+ ...(inv.clientId !== undefined && { clientId: inv.clientId }),
93
+ };
94
+ try {
95
+ const recorded = recordMutations(next, (draft) =>
96
+ optimistic(
97
+ draft as never,
98
+ { method: inv.method, params: inv.params } as Statewire.Command,
99
+ ctx,
100
+ ),
101
+ );
102
+ next = recorded.state;
103
+ paths = [...paths, ...recorded.changedPaths];
104
+ } catch (error) {
105
+ overlay.dropped = true;
106
+ console.error(
107
+ `statewire: optimistic reducer threw for "${inv.method}"; dropping its optimistic update`,
108
+ error,
109
+ );
110
+ }
111
+ }
112
+ }
113
+ published = next;
114
+ overlayPaths = paths;
115
+ };
116
+
117
+ const getConnection = (): StatewireClient.Connection =>
118
+ value?.connection ?? LIVE_CONNECTION;
119
+
120
+ const flush = () => {
121
+ if (!value) return;
122
+ if (flushing) {
123
+ schedule();
124
+ return;
125
+ }
126
+ flushing = true;
127
+ try {
128
+ recompute();
129
+ const options = getOptions();
130
+ const stateChanged = published !== notifiedPublished;
131
+ if (stateChanged) {
132
+ const prev = notifiedPublished;
133
+ const changed = [
134
+ ...pendingChanged,
135
+ ...notifiedOverlayPaths,
136
+ ...overlayPaths,
137
+ ];
138
+ pendingChanged = [];
139
+ notifiedPublished = published;
140
+ notifiedOverlayPaths = overlayPaths;
141
+ options.onStateChange?.(published as never, {
142
+ changes: createChanges(prev, published, changed),
143
+ });
144
+ }
145
+ if (updates.length > 0 && published !== undefined) {
146
+ const batch = updates;
147
+ updates = [];
148
+ for (const update of batch) {
149
+ options.onCommandChange?.(update, { state: published as never });
150
+ }
151
+ }
152
+ if (events.length > 0 && published !== undefined) {
153
+ const batch = events;
154
+ events = [];
155
+ for (const event of batch) {
156
+ options.onEvent?.(event, { state: published as never });
157
+ }
158
+ }
159
+ const connection = getConnection();
160
+ const connectionChanged = connection !== notifiedConnection;
161
+ notifiedConnection = connection;
162
+ if (stateChanged || connectionChanged) {
163
+ for (const listener of [...listeners]) listener();
164
+ }
165
+ } finally {
166
+ flushing = false;
167
+ }
168
+ };
169
+
170
+ const note = (
171
+ inv: CommandsInvocation,
172
+ status: Statewire.CommandUpdate["status"],
173
+ extra?: { result?: unknown; failure?: StatewireClient.SendFailure },
174
+ ) => {
175
+ if (!getOptions().onCommandChange) return;
176
+ updates.push({
177
+ method: inv.method,
178
+ params: inv.params as never[],
179
+ seq: inv.seq,
180
+ status,
181
+ ...extra,
182
+ });
183
+ schedule();
184
+ };
185
+
186
+ const commandsListener: CommandsListener = {
187
+ onStart: (inv) => {
188
+ overlays.push({ inv, dropped: false });
189
+ overlaysDirty = true;
190
+ markStale();
191
+ note(inv, "queued");
192
+ },
193
+ onPosted: (inv) => note(inv, "posted"),
194
+ onAck: (inv) => note(inv, "acked"),
195
+ onCovered: (inv) => {
196
+ if (!overlays.some((o) => o.inv === inv)) return;
197
+ overlays = overlays.filter((o) => o.inv !== inv);
198
+ overlaysDirty = true;
199
+ markStale();
200
+ },
201
+ onSettle: (inv, outcome) => {
202
+ note(
203
+ inv,
204
+ "settled",
205
+ outcome.type === "accepted"
206
+ ? { result: outcome.payload }
207
+ : { failure: failureOf(outcome.error) },
208
+ );
209
+ flush();
210
+ },
211
+ };
212
+
213
+ const eventSink = (event: unknown) => {
214
+ if (!getOptions().onEvent) return;
215
+ events.push(event);
216
+ schedule();
217
+ };
218
+
219
+ const attach = (next: Statewire<unknown>) => {
220
+ if (next === value) return;
221
+ const prev = value;
222
+ value = next;
223
+ const nextSource =
224
+ next.source ??
225
+ (typeof next.state === "object" && next.state !== null
226
+ ? getStateHost(next.state)
227
+ : undefined) ??
228
+ null;
229
+ if (nextSource !== source) {
230
+ source?.setOnChange(null);
231
+ source = nextSource;
232
+ source?.setOnChange(markStale);
233
+ markStale();
234
+ }
235
+ if (next.commands !== prev?.commands) {
236
+ if (prev) getCommandsHost(prev.commands)?.setListener(null);
237
+ getCommandsHost(next.commands)?.setListener(commandsListener);
238
+ if (overlays.length > 0) {
239
+ overlays = [];
240
+ overlaysDirty = true;
241
+ }
242
+ markStale();
243
+ }
244
+ if (next.emit !== prev?.emit) {
245
+ if (prev) getEmitHost(prev.emit)?.setSink(null);
246
+ getEmitHost(next.emit)?.setSink(eventSink);
247
+ }
248
+ if (!source) markStale();
249
+ schedule();
250
+ };
251
+
252
+ const detach = () => {
253
+ source?.setOnChange(null);
254
+ source = null;
255
+ if (value) {
256
+ getCommandsHost(value.commands)?.setListener(null);
257
+ getEmitHost(value.emit)?.setSink(null);
258
+ value = null;
259
+ }
260
+ };
261
+
262
+ const commands = createMethodProxy<Statewire.CommandsProxy<any>>(
263
+ (method, params) => {
264
+ if (!value) {
265
+ return Promise.reject(
266
+ new StatewireSendError({ fate: "aborted", cause: "unmount" }),
267
+ );
268
+ }
269
+ const send = (
270
+ value.commands as Record<
271
+ string,
272
+ (...args: unknown[]) => Promise<unknown>
273
+ >
274
+ )[method]!;
275
+ const promise = send(...params);
276
+ flush();
277
+ return promise;
278
+ },
279
+ );
280
+
281
+ return {
282
+ attach,
283
+ detach,
284
+ getState: () => {
285
+ recompute();
286
+ return published;
287
+ },
288
+ commands,
289
+ subscribe: (listener: () => void) => {
290
+ listeners.add(listener);
291
+ return () => {
292
+ listeners.delete(listener);
293
+ };
294
+ },
295
+ };
296
+ };
297
+
298
+ export const useStatewireCore = (
299
+ getOptions: () => StatewireClient.Options<any, any, any>,
300
+ value: Statewire<unknown>,
301
+ ) => {
302
+ const [core] = useState(() => createCore(getOptions));
303
+ // Attach during render so the first snapshot reads the transport's state;
304
+ // re-attaching the same transport is a no-op and the single-slot host
305
+ // listeners make a swap land on the last writer.
306
+ core.attach(value);
307
+ useEffect(() => {
308
+ core.attach(value);
309
+ });
310
+ useEffect(() => () => core.detach(), [core]);
311
+ return core;
312
+ };