statewire 0.0.0-0 → 0.0.1
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/LICENSE +21 -0
- package/README.md +83 -1
- package/dist/client/changes.d.ts +7 -0
- package/dist/client/changes.d.ts.map +1 -0
- package/dist/client/changes.js +32 -0
- package/dist/client/changes.js.map +1 -0
- package/dist/client/connection.d.ts +84 -0
- package/dist/client/connection.d.ts.map +1 -0
- package/dist/client/connection.js +204 -0
- package/dist/client/connection.js.map +1 -0
- package/dist/client/core.d.ts +18 -0
- package/dist/client/core.d.ts.map +1 -0
- package/dist/client/core.js +269 -0
- package/dist/client/core.js.map +1 -0
- package/dist/client/optimistic.d.ts +10 -0
- package/dist/client/optimistic.d.ts.map +1 -0
- package/dist/client/optimistic.js +128 -0
- package/dist/client/optimistic.js.map +1 -0
- package/dist/client/session.d.ts +88 -0
- package/dist/client/session.d.ts.map +1 -0
- package/dist/client/session.js +509 -0
- package/dist/client/session.js.map +1 -0
- package/dist/client/sessionStore.d.ts +16 -0
- package/dist/client/sessionStore.d.ts.map +1 -0
- package/dist/client/sessionStore.js +14 -0
- package/dist/client/sessionStore.js.map +1 -0
- package/dist/client/timing.d.ts +10 -0
- package/dist/client/timing.d.ts.map +1 -0
- package/dist/client/timing.js +11 -0
- package/dist/client/timing.js.map +1 -0
- package/dist/host/draft.d.ts +14 -0
- package/dist/host/draft.d.ts.map +1 -0
- package/dist/host/draft.js +124 -0
- package/dist/host/draft.js.map +1 -0
- package/dist/host/engine.d.ts +14 -0
- package/dist/host/engine.d.ts.map +1 -0
- package/dist/host/engine.js +17 -0
- package/dist/host/engine.js.map +1 -0
- package/dist/host/host.d.ts +79 -0
- package/dist/host/host.d.ts.map +1 -0
- package/dist/host/host.js +303 -0
- package/dist/host/host.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +3 -0
- package/dist/statewire.d.ts +20 -0
- package/dist/statewire.d.ts.map +1 -0
- package/dist/statewire.js +65 -0
- package/dist/statewire.js.map +1 -0
- package/dist/transports/remote.d.ts +19 -0
- package/dist/transports/remote.d.ts.map +1 -0
- package/dist/transports/remote.js +40 -0
- package/dist/transports/remote.js.map +1 -0
- package/dist/transports/sse-parser.d.ts +5 -0
- package/dist/transports/sse-parser.d.ts.map +1 -0
- package/dist/transports/sse-parser.js +26 -0
- package/dist/transports/sse-parser.js.map +1 -0
- package/dist/transports/sse.d.ts +74 -0
- package/dist/transports/sse.d.ts.map +1 -0
- package/dist/transports/sse.js +136 -0
- package/dist/transports/sse.js.map +1 -0
- package/dist/transports/testing.d.ts +31 -0
- package/dist/transports/testing.d.ts.map +1 -0
- package/dist/transports/testing.js +121 -0
- package/dist/transports/testing.js.map +1 -0
- package/dist/transports/ws.d.ts +18 -0
- package/dist/transports/ws.d.ts.map +1 -0
- package/dist/transports/ws.js +172 -0
- package/dist/transports/ws.js.map +1 -0
- package/dist/types.d.ts +107 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +24 -0
- package/dist/types.js.map +1 -0
- package/dist/wire/meta.d.ts +22 -0
- package/dist/wire/meta.d.ts.map +1 -0
- package/dist/wire/meta.js +73 -0
- package/dist/wire/meta.js.map +1 -0
- package/dist/wire/ops.d.ts +40 -0
- package/dist/wire/ops.d.ts.map +1 -0
- package/dist/wire/ops.js +125 -0
- package/dist/wire/ops.js.map +1 -0
- package/package.json +49 -6
- package/src/client/changes.ts +48 -0
- package/src/client/connection.ts +327 -0
- package/src/client/core.ts +346 -0
- package/src/client/optimistic.ts +0 -0
- package/src/client/session.ts +679 -0
- package/src/client/sessionStore.ts +23 -0
- package/src/client/timing.ts +8 -0
- package/src/host/draft.ts +155 -0
- package/src/host/engine.ts +13 -0
- package/src/host/host.ts +481 -0
- package/src/index.ts +65 -0
- package/src/server.ts +32 -0
- package/src/statewire.ts +107 -0
- package/src/transports/remote.ts +72 -0
- package/src/transports/sse-parser.ts +24 -0
- package/src/transports/sse.ts +280 -0
- package/src/transports/testing.ts +155 -0
- package/src/transports/ws.ts +272 -0
- package/src/types.ts +169 -0
- package/src/wire/meta.ts +106 -0
- package/src/wire/ops.ts +201 -0
- package/index.js +0 -1
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { createChanges } from "./changes";
|
|
2
|
+
import { getCommandsHost, getEmitHost, getStateHost } from "../host/host";
|
|
3
|
+
import type { CommandsInvocation, CommandsListener } from "../host/host";
|
|
4
|
+
import { recordMutations } from "./optimistic";
|
|
5
|
+
import type { StatewirePath } from "../wire/ops";
|
|
6
|
+
import { StatewireSendError } from "../types";
|
|
7
|
+
import type {
|
|
8
|
+
SendFailure as StatewireSendFailure,
|
|
9
|
+
StatewireStateSource,
|
|
10
|
+
StatewireTransport,
|
|
11
|
+
StatewireCommand,
|
|
12
|
+
StatewireCommandsProxy,
|
|
13
|
+
StatewireCommandUpdate,
|
|
14
|
+
StatewireOptimisticContext,
|
|
15
|
+
StatewireOptions,
|
|
16
|
+
} from "../types";
|
|
17
|
+
import type { StatewireConnection } from "../transports/sse";
|
|
18
|
+
|
|
19
|
+
export const LIVE_CONNECTION: StatewireConnection = {
|
|
20
|
+
status: "live",
|
|
21
|
+
reconnect: () => {},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const failureOf = (error: unknown): StatewireSendFailure =>
|
|
25
|
+
error instanceof StatewireSendError
|
|
26
|
+
? error.failure
|
|
27
|
+
: {
|
|
28
|
+
fate: "rejected",
|
|
29
|
+
cause: "server",
|
|
30
|
+
message: error instanceof Error ? error.message : String(error),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// MessageChannel schedules the flush as a macrotask (tap's pattern): it runs
|
|
34
|
+
// after the whole microtask drain, so every producer in the current turn
|
|
35
|
+
// coalesces. The port is ref'd only while a flush is pending — an active
|
|
36
|
+
// MessagePort holds the Node event loop open. ref/unref are Node-only.
|
|
37
|
+
const createMacrotaskPoster = (callback: () => void): (() => void) => {
|
|
38
|
+
if (typeof MessageChannel !== "undefined") {
|
|
39
|
+
let port1: (MessagePort & { ref?: () => void; unref?: () => void }) | null =
|
|
40
|
+
null;
|
|
41
|
+
let port2: MessagePort;
|
|
42
|
+
return () => {
|
|
43
|
+
if (!port1) {
|
|
44
|
+
const channel = new MessageChannel();
|
|
45
|
+
channel.port1.onmessage = () => {
|
|
46
|
+
port1?.unref?.();
|
|
47
|
+
callback();
|
|
48
|
+
};
|
|
49
|
+
port1 = channel.port1;
|
|
50
|
+
port2 = channel.port2;
|
|
51
|
+
}
|
|
52
|
+
port1.ref?.();
|
|
53
|
+
port2!.postMessage(null);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return () => setTimeout(callback, 0);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type Overlay = { inv: CommandsInvocation; dropped: boolean };
|
|
60
|
+
|
|
61
|
+
export type StatewireCore = ReturnType<typeof createStatewireCore>;
|
|
62
|
+
|
|
63
|
+
export const createStatewireCore = (
|
|
64
|
+
getOptions: () => StatewireOptions<any, any, any>,
|
|
65
|
+
) => {
|
|
66
|
+
let value: StatewireTransport<unknown> | null = null;
|
|
67
|
+
let source: StatewireStateSource | null = null;
|
|
68
|
+
let overlays: Overlay[] = [];
|
|
69
|
+
let updates: StatewireCommandUpdate[] = [];
|
|
70
|
+
let events: unknown[] = [];
|
|
71
|
+
let pendingChanged: StatewirePath[] = [];
|
|
72
|
+
let canonical: unknown;
|
|
73
|
+
let published: unknown;
|
|
74
|
+
let overlayPaths: readonly StatewirePath[] = [];
|
|
75
|
+
let notifiedPublished: unknown;
|
|
76
|
+
let notifiedOverlayPaths: readonly StatewirePath[] = [];
|
|
77
|
+
let notifiedConnection: StatewireConnection | null = null;
|
|
78
|
+
let stale = false;
|
|
79
|
+
let overlaysDirty = false;
|
|
80
|
+
let flushing = false;
|
|
81
|
+
let disposed = false;
|
|
82
|
+
const listeners = new Set<() => void>();
|
|
83
|
+
|
|
84
|
+
const post = createMacrotaskPoster(() => {
|
|
85
|
+
scheduled = false;
|
|
86
|
+
flush();
|
|
87
|
+
});
|
|
88
|
+
let scheduled = false;
|
|
89
|
+
const schedule = () => {
|
|
90
|
+
if (scheduled || disposed) return;
|
|
91
|
+
scheduled = true;
|
|
92
|
+
post();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const markStale = () => {
|
|
96
|
+
stale = true;
|
|
97
|
+
schedule();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const drainCanonical = () => {
|
|
101
|
+
if (source) {
|
|
102
|
+
pendingChanged.push(...source.takeChanged());
|
|
103
|
+
canonical = source.root();
|
|
104
|
+
} else if (value && value.state !== canonical) {
|
|
105
|
+
pendingChanged.push([]);
|
|
106
|
+
canonical = value.state;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const recompute = () => {
|
|
111
|
+
if (!stale || !value) return;
|
|
112
|
+
stale = false;
|
|
113
|
+
const prevCanonical = canonical;
|
|
114
|
+
drainCanonical();
|
|
115
|
+
if (canonical === prevCanonical && !overlaysDirty) return;
|
|
116
|
+
overlaysDirty = false;
|
|
117
|
+
const { optimistic } = getOptions();
|
|
118
|
+
let next = canonical;
|
|
119
|
+
let paths: readonly StatewirePath[] = [];
|
|
120
|
+
if (optimistic && canonical !== undefined) {
|
|
121
|
+
for (const overlay of overlays) {
|
|
122
|
+
if (overlay.dropped) continue;
|
|
123
|
+
const { inv } = overlay;
|
|
124
|
+
const ctx: StatewireOptimisticContext = {
|
|
125
|
+
ts: inv.ts,
|
|
126
|
+
seq: inv.seq,
|
|
127
|
+
...(inv.clientId !== undefined && { clientId: inv.clientId }),
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
const recorded = recordMutations(next, (draft) =>
|
|
131
|
+
optimistic(
|
|
132
|
+
draft as never,
|
|
133
|
+
{ method: inv.method, params: inv.params } as StatewireCommand,
|
|
134
|
+
ctx,
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
next = recorded.state;
|
|
138
|
+
paths = [...paths, ...recorded.changedPaths];
|
|
139
|
+
} catch (error) {
|
|
140
|
+
overlay.dropped = true;
|
|
141
|
+
console.error(
|
|
142
|
+
`statewire: optimistic reducer threw for "${inv.method}"; dropping its optimistic update`,
|
|
143
|
+
error,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
published = next;
|
|
149
|
+
overlayPaths = paths;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const getConnection = (): StatewireConnection =>
|
|
153
|
+
value?.connection ?? LIVE_CONNECTION;
|
|
154
|
+
|
|
155
|
+
const flush = () => {
|
|
156
|
+
if (disposed || !value) return;
|
|
157
|
+
if (flushing) {
|
|
158
|
+
schedule();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
flushing = true;
|
|
162
|
+
try {
|
|
163
|
+
recompute();
|
|
164
|
+
const options = getOptions();
|
|
165
|
+
const stateChanged = published !== notifiedPublished;
|
|
166
|
+
if (stateChanged) {
|
|
167
|
+
const prev = notifiedPublished;
|
|
168
|
+
const changed = [
|
|
169
|
+
...pendingChanged,
|
|
170
|
+
...notifiedOverlayPaths,
|
|
171
|
+
...overlayPaths,
|
|
172
|
+
];
|
|
173
|
+
pendingChanged = [];
|
|
174
|
+
notifiedPublished = published;
|
|
175
|
+
notifiedOverlayPaths = overlayPaths;
|
|
176
|
+
options.onStateChange?.(published as never, {
|
|
177
|
+
changes: createChanges(prev, published, changed),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (updates.length > 0 && published !== undefined) {
|
|
181
|
+
const batch = updates;
|
|
182
|
+
updates = [];
|
|
183
|
+
for (const update of batch) {
|
|
184
|
+
options.onCommandChange?.(update, { state: published as never });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (events.length > 0) {
|
|
188
|
+
const batch = events;
|
|
189
|
+
events = [];
|
|
190
|
+
for (const event of batch) {
|
|
191
|
+
options.onEvent?.(event, { state: published as never });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const connection = getConnection();
|
|
195
|
+
const connectionChanged = connection !== notifiedConnection;
|
|
196
|
+
notifiedConnection = connection;
|
|
197
|
+
if (stateChanged || connectionChanged) {
|
|
198
|
+
for (const listener of [...listeners]) listener();
|
|
199
|
+
}
|
|
200
|
+
} finally {
|
|
201
|
+
flushing = false;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const note = (
|
|
206
|
+
inv: CommandsInvocation,
|
|
207
|
+
status: StatewireCommandUpdate["status"],
|
|
208
|
+
extra?: { result?: unknown; failure?: StatewireSendFailure },
|
|
209
|
+
) => {
|
|
210
|
+
if (!getOptions().onCommandChange) return;
|
|
211
|
+
updates.push({
|
|
212
|
+
method: inv.method,
|
|
213
|
+
params: inv.params as never[],
|
|
214
|
+
seq: inv.seq,
|
|
215
|
+
status,
|
|
216
|
+
...extra,
|
|
217
|
+
});
|
|
218
|
+
schedule();
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const commandsListener: CommandsListener = {
|
|
222
|
+
onStart: (inv) => {
|
|
223
|
+
overlays.push({ inv, dropped: false });
|
|
224
|
+
overlaysDirty = true;
|
|
225
|
+
markStale();
|
|
226
|
+
note(inv, "queued");
|
|
227
|
+
},
|
|
228
|
+
onPosted: (inv) => note(inv, "posted"),
|
|
229
|
+
onAck: (inv) => note(inv, "acked"),
|
|
230
|
+
onCovered: (inv) => {
|
|
231
|
+
if (!overlays.some((o) => o.inv === inv)) return;
|
|
232
|
+
overlays = overlays.filter((o) => o.inv !== inv);
|
|
233
|
+
overlaysDirty = true;
|
|
234
|
+
markStale();
|
|
235
|
+
},
|
|
236
|
+
onSettle: (inv, outcome) => {
|
|
237
|
+
note(
|
|
238
|
+
inv,
|
|
239
|
+
"settled",
|
|
240
|
+
outcome.type === "accepted"
|
|
241
|
+
? { result: outcome.payload }
|
|
242
|
+
: { failure: failureOf(outcome.error) },
|
|
243
|
+
);
|
|
244
|
+
flush();
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const eventSink = (event: unknown) => {
|
|
249
|
+
if (!getOptions().onEvent) return;
|
|
250
|
+
events.push(event);
|
|
251
|
+
schedule();
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const attach = (next: StatewireTransport<unknown>) => {
|
|
255
|
+
if (disposed || next === value) return;
|
|
256
|
+
const prev = value;
|
|
257
|
+
value = next;
|
|
258
|
+
const nextSource =
|
|
259
|
+
next.source ??
|
|
260
|
+
(typeof next.state === "object" && next.state !== null
|
|
261
|
+
? getStateHost(next.state)
|
|
262
|
+
: undefined) ??
|
|
263
|
+
null;
|
|
264
|
+
if (nextSource !== source) {
|
|
265
|
+
source?.setOnChange(null);
|
|
266
|
+
source = nextSource;
|
|
267
|
+
source?.setOnChange(markStale);
|
|
268
|
+
markStale();
|
|
269
|
+
}
|
|
270
|
+
if (next.commands !== prev?.commands) {
|
|
271
|
+
if (prev) getCommandsHost(prev.commands)?.setListener(null);
|
|
272
|
+
getCommandsHost(next.commands)?.setListener(commandsListener);
|
|
273
|
+
if (overlays.length > 0) {
|
|
274
|
+
overlays = [];
|
|
275
|
+
overlaysDirty = true;
|
|
276
|
+
}
|
|
277
|
+
markStale();
|
|
278
|
+
}
|
|
279
|
+
if (next.emit !== prev?.emit) {
|
|
280
|
+
if (prev) getEmitHost(prev.emit)?.setSink(null);
|
|
281
|
+
getEmitHost(next.emit)?.setSink(eventSink);
|
|
282
|
+
}
|
|
283
|
+
if (!source) markStale();
|
|
284
|
+
schedule();
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const detach = () => {
|
|
288
|
+
source?.setOnChange(null);
|
|
289
|
+
source = null;
|
|
290
|
+
if (value) {
|
|
291
|
+
getCommandsHost(value.commands)?.setListener(null);
|
|
292
|
+
getEmitHost(value.emit)?.setSink(null);
|
|
293
|
+
value = null;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const memo = new Map<string, (...params: unknown[]) => Promise<unknown>>();
|
|
298
|
+
const commands = new Proxy({} as StatewireCommandsProxy<any>, {
|
|
299
|
+
get: (_target, method) => {
|
|
300
|
+
if (typeof method !== "string") return undefined;
|
|
301
|
+
let fn = memo.get(method);
|
|
302
|
+
if (fn === undefined) {
|
|
303
|
+
fn = (...params: unknown[]) => {
|
|
304
|
+
if (!value) {
|
|
305
|
+
return Promise.reject(
|
|
306
|
+
new StatewireSendError({ fate: "aborted", cause: "unmount" }),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
const send = (
|
|
310
|
+
value.commands as Record<
|
|
311
|
+
string,
|
|
312
|
+
(...args: unknown[]) => Promise<unknown>
|
|
313
|
+
>
|
|
314
|
+
)[method]!;
|
|
315
|
+
const promise = send(...params);
|
|
316
|
+
flush();
|
|
317
|
+
return promise;
|
|
318
|
+
};
|
|
319
|
+
memo.set(method, fn);
|
|
320
|
+
}
|
|
321
|
+
return fn;
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
attach,
|
|
327
|
+
detach,
|
|
328
|
+
dispose: () => {
|
|
329
|
+
detach();
|
|
330
|
+
disposed = true;
|
|
331
|
+
},
|
|
332
|
+
getState: () => {
|
|
333
|
+
recompute();
|
|
334
|
+
return published;
|
|
335
|
+
},
|
|
336
|
+
getConnection,
|
|
337
|
+
commands,
|
|
338
|
+
flush,
|
|
339
|
+
subscribe: (listener: () => void) => {
|
|
340
|
+
listeners.add(listener);
|
|
341
|
+
return () => {
|
|
342
|
+
listeners.delete(listener);
|
|
343
|
+
};
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
};
|
|
Binary file
|