statewire 0.0.1 → 0.0.3
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/README.md +14 -13
- package/dist/client/changes.d.ts +2 -2
- package/dist/client/changes.d.ts.map +1 -1
- package/dist/client/changes.js +3 -10
- package/dist/client/changes.js.map +1 -1
- package/dist/client/connection.d.ts +18 -39
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +61 -54
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +7 -11
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +58 -55
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +12 -26
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +70 -43
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +1 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/client/timing.d.ts +2 -1
- package/dist/client/timing.d.ts.map +1 -1
- package/dist/client/timing.js +5 -1
- package/dist/client/timing.js.map +1 -1
- package/dist/host/adapter.d.ts +8 -0
- package/dist/host/adapter.d.ts.map +1 -0
- package/dist/host/adapter.js +23 -0
- package/dist/host/adapter.js.map +1 -0
- package/dist/host/draft.d.ts +21 -7
- package/dist/host/draft.d.ts.map +1 -1
- package/dist/host/draft.js +190 -103
- package/dist/host/draft.js.map +1 -1
- package/dist/host/host.d.ts +19 -35
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +86 -54
- package/dist/host/host.js.map +1 -1
- package/dist/host/socket.d.ts +14 -0
- package/dist/host/socket.d.ts.map +1 -0
- package/dist/host/socket.js +511 -0
- package/dist/host/socket.js.map +1 -0
- package/dist/host-internal.d.ts +27 -0
- package/dist/host-internal.d.ts.map +1 -0
- package/dist/host-internal.js +3 -0
- package/dist/host.d.ts +54 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +4 -0
- package/dist/index.d.ts +5 -28
- package/dist/index.js +2 -3
- package/dist/statewire.d.ts +97 -10
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +32 -36
- package/dist/statewire.js.map +1 -1
- package/dist/transports/fake-sse.d.ts +12 -0
- package/dist/transports/fake-sse.d.ts.map +1 -0
- package/dist/transports/fake-sse.js +39 -0
- package/dist/transports/fake-sse.js.map +1 -0
- package/dist/transports/fixtures.d.ts +33 -0
- package/dist/transports/fixtures.d.ts.map +1 -0
- package/dist/transports/fixtures.js +50 -0
- package/dist/transports/fixtures.js.map +1 -0
- package/dist/transports/remote.d.ts +6 -13
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +7 -25
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +32 -20
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +57 -46
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +8 -6
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +19 -46
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +4 -13
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +59 -63
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +55 -100
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/util/draftTraps.d.ts +6 -0
- package/dist/util/draftTraps.d.ts.map +1 -0
- package/dist/util/draftTraps.js +25 -0
- package/dist/util/draftTraps.js.map +1 -0
- package/dist/util/macrotask.d.ts +5 -0
- package/dist/util/macrotask.d.ts.map +1 -0
- package/dist/util/macrotask.js +25 -0
- package/dist/util/macrotask.js.map +1 -0
- package/dist/util/methodProxy.d.ts +5 -0
- package/dist/util/methodProxy.d.ts.map +1 -0
- package/dist/util/methodProxy.js +17 -0
- package/dist/util/methodProxy.js.map +1 -0
- package/dist/util/randomId.d.ts +5 -0
- package/dist/util/randomId.d.ts.map +1 -0
- package/dist/util/randomId.js +6 -0
- package/dist/util/randomId.js.map +1 -0
- package/dist/{client/optimistic.d.ts → util/recordMutations.d.ts} +2 -2
- package/dist/util/recordMutations.d.ts.map +1 -0
- package/dist/{client/optimistic.js → util/recordMutations.js} +5 -24
- package/dist/util/recordMutations.js.map +1 -0
- package/dist/util/statePath.d.ts +14 -0
- package/dist/util/statePath.d.ts.map +1 -0
- package/dist/util/statePath.js +31 -0
- package/dist/util/statePath.js.map +1 -0
- package/dist/wire/meta.d.ts +6 -1
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +30 -28
- package/dist/wire/meta.js.map +1 -1
- package/dist/wire/ops.d.ts +5 -26
- package/dist/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +67 -80
- package/dist/wire/ops.js.map +1 -1
- package/package.json +15 -11
- package/src/client/changes.ts +5 -15
- package/src/client/connection.ts +87 -113
- package/src/client/core.ts +57 -91
- package/src/client/session.ts +129 -90
- package/src/client/sessionStore.ts +1 -1
- package/src/client/timing.ts +6 -0
- package/src/host/adapter.ts +33 -0
- package/src/host/draft.ts +293 -125
- package/src/host/host.ts +122 -110
- package/src/host/socket.ts +618 -0
- package/src/host-internal.ts +28 -0
- package/src/host.ts +72 -0
- package/src/index.ts +2 -62
- package/src/statewire.ts +168 -61
- package/src/transports/fake-sse.ts +51 -0
- package/src/transports/fixtures.ts +60 -0
- package/src/transports/remote.ts +14 -51
- package/src/transports/sse.ts +87 -89
- package/src/transports/testing.ts +33 -63
- package/src/transports/ws.ts +78 -100
- package/src/types.ts +52 -131
- package/src/util/draftTraps.ts +32 -0
- package/src/util/macrotask.ts +25 -0
- package/src/util/methodProxy.ts +16 -0
- package/src/util/randomId.ts +4 -0
- package/src/{client/optimistic.ts → util/recordMutations.ts} +0 -0
- package/src/util/statePath.ts +61 -0
- package/src/wire/meta.ts +42 -47
- package/src/wire/ops.ts +107 -106
- package/LICENSE +0 -21
- package/dist/client/optimistic.d.ts.map +0 -1
- package/dist/client/optimistic.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/server.d.ts +0 -15
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js +0 -3
- package/src/server.ts +0 -32
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
|
|
3
|
+
import type { ResourceElement } from "@assistant-ui/tap";
|
|
4
|
+
import { getCommandsHost, getEmitHost, getStateHost } from "./host";
|
|
5
|
+
import type { CommandsHost, CommandsOutcome } from "./host";
|
|
6
|
+
import { StatewireSendError } from "../types";
|
|
7
|
+
import type { Statewire } from "../types";
|
|
8
|
+
import type { StatewireHost } from "../host";
|
|
9
|
+
import type { StatewireHostInternal } from "../host-internal";
|
|
10
|
+
import { encodeValue, reviveValue } from "../wire/meta";
|
|
11
|
+
import { createMacrotaskPoster } from "../util/macrotask";
|
|
12
|
+
import { randomId } from "../util/randomId";
|
|
13
|
+
import type { StatewireMetaEntry } from "../wire/meta";
|
|
14
|
+
import type { StatewirePath } from "../wire/ops";
|
|
15
|
+
import type {
|
|
16
|
+
StatewireEnvelope,
|
|
17
|
+
StatewireResponse,
|
|
18
|
+
} from "../client/connection";
|
|
19
|
+
|
|
20
|
+
const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
|
|
21
|
+
const RESULT_TTL_MS = 30_000;
|
|
22
|
+
const RESULT_LIMIT = 256;
|
|
23
|
+
|
|
24
|
+
type WireOp = {
|
|
25
|
+
op: string;
|
|
26
|
+
path?: StatewirePath;
|
|
27
|
+
value?: unknown;
|
|
28
|
+
meta?: StatewireMetaEntry[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type Admission = {
|
|
32
|
+
seq: number;
|
|
33
|
+
method: string;
|
|
34
|
+
acked: boolean;
|
|
35
|
+
pendingSent: boolean;
|
|
36
|
+
settled: boolean;
|
|
37
|
+
release: () => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type ClientRec = {
|
|
41
|
+
id: string;
|
|
42
|
+
lastSeq: number;
|
|
43
|
+
context: Record<string, unknown> | null;
|
|
44
|
+
results: Map<number, { ts: number; res: StatewireResponse }>;
|
|
45
|
+
inflight: Map<number, Admission>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type Conn = {
|
|
49
|
+
clientId: string;
|
|
50
|
+
socket: StatewireHostInternal.Socket;
|
|
51
|
+
open: boolean;
|
|
52
|
+
lastSentAt: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type Frame = {
|
|
56
|
+
method?: string;
|
|
57
|
+
seq: number;
|
|
58
|
+
params: unknown[];
|
|
59
|
+
context?: unknown;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const parseFrame = (raw: string): Frame | null => {
|
|
63
|
+
let frame: unknown;
|
|
64
|
+
try {
|
|
65
|
+
frame = JSON.parse(raw);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (typeof frame !== "object" || frame === null || Array.isArray(frame)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const { method, seq, params, meta, context } = frame as Record<
|
|
73
|
+
string,
|
|
74
|
+
unknown
|
|
75
|
+
>;
|
|
76
|
+
if (method === undefined && context === undefined) return null;
|
|
77
|
+
if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) return null;
|
|
78
|
+
if (method === undefined) {
|
|
79
|
+
return { seq, params: [], ...(context !== undefined && { context }) };
|
|
80
|
+
}
|
|
81
|
+
if (typeof method !== "string") return null;
|
|
82
|
+
if (!Array.isArray(params)) return null;
|
|
83
|
+
if (meta !== undefined) {
|
|
84
|
+
try {
|
|
85
|
+
reviveValue(params, meta);
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { method, seq, params, ...(context !== undefined && { context }) };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** v1 context submissions are exactly one root replace with an object value. */
|
|
94
|
+
const contextValue = (context: unknown): Record<string, unknown> => {
|
|
95
|
+
if (!Array.isArray(context) || context.length !== 1) {
|
|
96
|
+
throw new Error("context must be a single-op array");
|
|
97
|
+
}
|
|
98
|
+
const op = context[0] as Record<string, unknown> | null;
|
|
99
|
+
if (
|
|
100
|
+
typeof op !== "object" ||
|
|
101
|
+
op === null ||
|
|
102
|
+
op["op"] !== "replace" ||
|
|
103
|
+
!Array.isArray(op["path"]) ||
|
|
104
|
+
op["path"].length !== 0
|
|
105
|
+
) {
|
|
106
|
+
throw new Error("context op must be a root replace");
|
|
107
|
+
}
|
|
108
|
+
const value = op["value"];
|
|
109
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
110
|
+
throw new Error("context value must be an object");
|
|
111
|
+
}
|
|
112
|
+
return value as Record<string, unknown>;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const encodePayload = (
|
|
116
|
+
payload: unknown,
|
|
117
|
+
): { payload?: unknown; meta?: StatewireMetaEntry[] } => {
|
|
118
|
+
if (payload === undefined) return {};
|
|
119
|
+
const { value, meta } = encodeValue(payload);
|
|
120
|
+
return { payload: value, ...(meta !== undefined && { meta }) };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const responseOf = (
|
|
124
|
+
adm: Admission,
|
|
125
|
+
outcome: CommandsOutcome,
|
|
126
|
+
): StatewireResponse => {
|
|
127
|
+
if (outcome.type === "accepted") {
|
|
128
|
+
return {
|
|
129
|
+
seq: adm.seq,
|
|
130
|
+
type: "accepted",
|
|
131
|
+
...encodePayload(outcome.payload),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const failure =
|
|
135
|
+
outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
|
|
136
|
+
switch (failure?.fate) {
|
|
137
|
+
case "unknown-command":
|
|
138
|
+
return {
|
|
139
|
+
seq: adm.seq,
|
|
140
|
+
type: "rejected",
|
|
141
|
+
code: "unknown-command",
|
|
142
|
+
message: `unknown method: '${adm.method}'`,
|
|
143
|
+
};
|
|
144
|
+
case "invalid-params":
|
|
145
|
+
return {
|
|
146
|
+
seq: adm.seq,
|
|
147
|
+
type: "rejected",
|
|
148
|
+
code: "invalid-params",
|
|
149
|
+
message: failure.message,
|
|
150
|
+
};
|
|
151
|
+
case "crashed":
|
|
152
|
+
return { seq: adm.seq, type: "crashed" };
|
|
153
|
+
case "rejected":
|
|
154
|
+
return {
|
|
155
|
+
seq: adm.seq,
|
|
156
|
+
type: "rejected",
|
|
157
|
+
message: failure.message,
|
|
158
|
+
...encodePayload(failure.payload),
|
|
159
|
+
};
|
|
160
|
+
default:
|
|
161
|
+
return {
|
|
162
|
+
seq: adm.seq,
|
|
163
|
+
type: "rejected",
|
|
164
|
+
message:
|
|
165
|
+
outcome.error instanceof Error
|
|
166
|
+
? outcome.error.message
|
|
167
|
+
: String(outcome.error),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const createSocketHost = (
|
|
173
|
+
options?: StatewireHostInternal.SocketHostOptions,
|
|
174
|
+
) => {
|
|
175
|
+
const heartbeatMs = options?.heartbeatIntervalMs ?? 15_000;
|
|
176
|
+
|
|
177
|
+
const clients = new Map<string, ClientRec>();
|
|
178
|
+
const conns = new Set<Conn>();
|
|
179
|
+
let pendingOps: WireOp[] = [];
|
|
180
|
+
let dirtyAcks = new Set<string>();
|
|
181
|
+
let pendingRes = new Map<string, StatewireResponse[]>();
|
|
182
|
+
let inbox: Promise<void> = Promise.resolve();
|
|
183
|
+
let finished: StatewireHost.Fin | null = null;
|
|
184
|
+
let disposed = false;
|
|
185
|
+
let flushScheduled = false;
|
|
186
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
187
|
+
|
|
188
|
+
let stateHost: StatewireHost.StateHost | null = null;
|
|
189
|
+
let commandsHost: CommandsHost | null = null;
|
|
190
|
+
let emitHost: ReturnType<typeof getEmitHost> | null = null;
|
|
191
|
+
let current: Statewire<unknown> | null = null;
|
|
192
|
+
|
|
193
|
+
const post = createMacrotaskPoster(() => {
|
|
194
|
+
flushScheduled = false;
|
|
195
|
+
flush();
|
|
196
|
+
});
|
|
197
|
+
const scheduleFlush = () => {
|
|
198
|
+
if (flushScheduled || disposed) return;
|
|
199
|
+
flushScheduled = true;
|
|
200
|
+
post();
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const recordOp = (op: {
|
|
204
|
+
op: string;
|
|
205
|
+
path?: StatewirePath;
|
|
206
|
+
value?: unknown;
|
|
207
|
+
}) => {
|
|
208
|
+
if (op.op === "replace" || op.op === "add" || op.op === "event") {
|
|
209
|
+
const { value, meta } = encodeValue(op.value);
|
|
210
|
+
pendingOps.push({ ...op, value, ...(meta !== undefined && { meta }) });
|
|
211
|
+
} else {
|
|
212
|
+
pendingOps.push(op);
|
|
213
|
+
}
|
|
214
|
+
scheduleFlush();
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const attach = (next: Statewire<unknown>) => {
|
|
218
|
+
if (next === current) return;
|
|
219
|
+
current = next;
|
|
220
|
+
const nextState =
|
|
221
|
+
typeof next.state === "object" && next.state !== null
|
|
222
|
+
? getStateHost(next.state)
|
|
223
|
+
: undefined;
|
|
224
|
+
const nextCommands = getCommandsHost(next.commands);
|
|
225
|
+
const nextEmit = getEmitHost(next.emit);
|
|
226
|
+
if (!nextState || !nextCommands?.submit || !nextEmit) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
"StatewireSocketHost requires a statewire/host transport (useStatewireState with object state, useStatewireCommands, useStatewireEmit)",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (nextState !== stateHost) {
|
|
232
|
+
stateHost?.setOnOp(null);
|
|
233
|
+
stateHost = nextState;
|
|
234
|
+
stateHost.setOnOp(recordOp);
|
|
235
|
+
}
|
|
236
|
+
commandsHost = nextCommands;
|
|
237
|
+
if (nextEmit !== emitHost) {
|
|
238
|
+
emitHost?.setSink(null);
|
|
239
|
+
emitHost = nextEmit;
|
|
240
|
+
emitHost.setSink((event) => recordOp({ op: "event", value: event }));
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const closeConn = (conn: Conn, code?: number, reason?: string) => {
|
|
245
|
+
if (!conn.open) return;
|
|
246
|
+
conn.open = false;
|
|
247
|
+
conns.delete(conn);
|
|
248
|
+
try {
|
|
249
|
+
conn.socket.close(code, reason);
|
|
250
|
+
} catch {
|
|
251
|
+
/* peer already gone */
|
|
252
|
+
}
|
|
253
|
+
if (conns.size === 0 && heartbeatTimer !== null) {
|
|
254
|
+
clearInterval(heartbeatTimer);
|
|
255
|
+
heartbeatTimer = null;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const sendTo = (conn: Conn, data: string) => {
|
|
260
|
+
if (!conn.open) return;
|
|
261
|
+
conn.lastSentAt = Date.now();
|
|
262
|
+
try {
|
|
263
|
+
conn.socket.send(data);
|
|
264
|
+
} catch {
|
|
265
|
+
closeConn(conn);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const ensureHeartbeat = () => {
|
|
270
|
+
if (heartbeatTimer !== null || heartbeatMs <= 0) return;
|
|
271
|
+
heartbeatTimer = setInterval(() => {
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
for (const conn of [...conns]) {
|
|
274
|
+
if (now - conn.lastSentAt >= heartbeatMs) sendTo(conn, "{}");
|
|
275
|
+
}
|
|
276
|
+
}, heartbeatMs);
|
|
277
|
+
(heartbeatTimer as unknown as { unref?: () => void }).unref?.();
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const flush = (fin?: StatewireHost.Fin) => {
|
|
281
|
+
flushTapSync(() => {});
|
|
282
|
+
stateHost?.takeChanged();
|
|
283
|
+
for (const rec of clients.values()) {
|
|
284
|
+
for (const adm of rec.inflight.values()) {
|
|
285
|
+
if (adm.acked && !adm.settled && !adm.pendingSent) {
|
|
286
|
+
adm.pendingSent = true;
|
|
287
|
+
queueRes(rec.id, { seq: adm.seq, type: "pending" });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (
|
|
292
|
+
pendingOps.length === 0 &&
|
|
293
|
+
dirtyAcks.size === 0 &&
|
|
294
|
+
pendingRes.size === 0 &&
|
|
295
|
+
fin === undefined
|
|
296
|
+
) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const ops = pendingOps;
|
|
300
|
+
const acks = dirtyAcks;
|
|
301
|
+
const res = pendingRes;
|
|
302
|
+
pendingOps = [];
|
|
303
|
+
dirtyAcks = new Set();
|
|
304
|
+
pendingRes = new Map();
|
|
305
|
+
for (const conn of [...conns]) {
|
|
306
|
+
const envelope: StatewireEnvelope = {};
|
|
307
|
+
if (ops.length > 0) envelope.ops = ops;
|
|
308
|
+
const clientRes = res.get(conn.clientId);
|
|
309
|
+
if (clientRes !== undefined) envelope.res = clientRes;
|
|
310
|
+
if (acks.has(conn.clientId)) {
|
|
311
|
+
const rec = clients.get(conn.clientId);
|
|
312
|
+
if (rec !== undefined) envelope.ack = rec.lastSeq;
|
|
313
|
+
}
|
|
314
|
+
if (fin !== undefined) envelope.fin = fin;
|
|
315
|
+
if (Object.keys(envelope).length === 0) continue;
|
|
316
|
+
sendTo(conn, JSON.stringify(envelope));
|
|
317
|
+
if (fin !== undefined) closeConn(conn);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const queueRes = (clientId: string, response: StatewireResponse) => {
|
|
322
|
+
const list = pendingRes.get(clientId);
|
|
323
|
+
if (list) list.push(response);
|
|
324
|
+
else pendingRes.set(clientId, [response]);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const advance = (rec: ClientRec, seq: number) => {
|
|
328
|
+
rec.lastSeq = seq;
|
|
329
|
+
dirtyAcks.add(rec.id);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const retain = (rec: ClientRec, response: StatewireResponse) => {
|
|
333
|
+
const now = Date.now();
|
|
334
|
+
rec.results.set(response.seq, { ts: now, res: response });
|
|
335
|
+
const newest = Math.max(...rec.results.keys());
|
|
336
|
+
for (const [seq, entry] of rec.results) {
|
|
337
|
+
if (seq !== newest && now - entry.ts > RESULT_TTL_MS) {
|
|
338
|
+
rec.results.delete(seq);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
while (rec.results.size > RESULT_LIMIT) {
|
|
342
|
+
rec.results.delete(Math.min(...rec.results.keys()));
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const duplicateAnswer = (rec: ClientRec, seq: number): StatewireResponse => {
|
|
347
|
+
const cached = rec.results.get(seq);
|
|
348
|
+
if (cached) {
|
|
349
|
+
const newest = Math.max(...rec.results.keys());
|
|
350
|
+
if (seq === newest || Date.now() - cached.ts <= RESULT_TTL_MS) {
|
|
351
|
+
return cached.res;
|
|
352
|
+
}
|
|
353
|
+
rec.results.delete(seq);
|
|
354
|
+
}
|
|
355
|
+
if (rec.inflight.has(seq)) return { seq, type: "pending" };
|
|
356
|
+
return { seq, type: "result-unavailable" };
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const settleResponse = (
|
|
360
|
+
rec: ClientRec,
|
|
361
|
+
adm: Admission,
|
|
362
|
+
response: StatewireResponse,
|
|
363
|
+
) => {
|
|
364
|
+
if (adm.settled) return;
|
|
365
|
+
adm.settled = true;
|
|
366
|
+
rec.inflight.delete(adm.seq);
|
|
367
|
+
retain(rec, response);
|
|
368
|
+
if (!adm.acked) {
|
|
369
|
+
adm.acked = true;
|
|
370
|
+
advance(rec, adm.seq);
|
|
371
|
+
}
|
|
372
|
+
if (
|
|
373
|
+
adm.pendingSent ||
|
|
374
|
+
response.type !== "accepted" ||
|
|
375
|
+
response.payload !== undefined
|
|
376
|
+
) {
|
|
377
|
+
queueRes(rec.id, response);
|
|
378
|
+
}
|
|
379
|
+
scheduleFlush();
|
|
380
|
+
adm.release();
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const settleWire = (
|
|
384
|
+
rec: ClientRec,
|
|
385
|
+
adm: Admission,
|
|
386
|
+
outcome: CommandsOutcome,
|
|
387
|
+
) => settleResponse(rec, adm, responseOf(adm, outcome));
|
|
388
|
+
|
|
389
|
+
const callerOf = (rec: ClientRec): StatewireHost.Caller => ({
|
|
390
|
+
get context() {
|
|
391
|
+
return rec.context;
|
|
392
|
+
},
|
|
393
|
+
get isConnected() {
|
|
394
|
+
if (clients.get(rec.id) !== rec) return false;
|
|
395
|
+
for (const conn of conns) {
|
|
396
|
+
if (conn.clientId === rec.id && conn.open) return true;
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const admit = (conn: Conn, frame: Frame): Promise<void> | void => {
|
|
403
|
+
if (!conn.open || disposed || finished !== null) return;
|
|
404
|
+
const rec = clients.get(conn.clientId)!;
|
|
405
|
+
if (frame.seq <= rec.lastSeq || rec.inflight.has(frame.seq)) {
|
|
406
|
+
queueRes(rec.id, duplicateAnswer(rec, frame.seq));
|
|
407
|
+
scheduleFlush();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (frame.seq > rec.lastSeq + 1) {
|
|
411
|
+
closeConn(
|
|
412
|
+
conn,
|
|
413
|
+
1008,
|
|
414
|
+
`seq gap: expected ${rec.lastSeq + 1}, got ${frame.seq}`,
|
|
415
|
+
);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const adm: Admission = {
|
|
419
|
+
seq: frame.seq,
|
|
420
|
+
method: frame.method ?? "",
|
|
421
|
+
acked: false,
|
|
422
|
+
pendingSent: false,
|
|
423
|
+
settled: false,
|
|
424
|
+
release: () => {},
|
|
425
|
+
};
|
|
426
|
+
if (frame.context !== undefined) {
|
|
427
|
+
let value: Record<string, unknown>;
|
|
428
|
+
try {
|
|
429
|
+
value = contextValue(frame.context);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
rec.inflight.set(frame.seq, adm);
|
|
432
|
+
settleResponse(rec, adm, {
|
|
433
|
+
seq: frame.seq,
|
|
434
|
+
type: "rejected",
|
|
435
|
+
code: "invalid-context",
|
|
436
|
+
message: `invalid context: ${(error as Error).message}`,
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
rec.context = value;
|
|
441
|
+
if (frame.method === undefined) {
|
|
442
|
+
rec.inflight.set(frame.seq, adm);
|
|
443
|
+
settleResponse(rec, adm, { seq: frame.seq, type: "accepted" });
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const released = new Promise<void>((resolve) => {
|
|
448
|
+
adm.release = resolve;
|
|
449
|
+
});
|
|
450
|
+
rec.inflight.set(frame.seq, adm);
|
|
451
|
+
commandsHost!.submit!({
|
|
452
|
+
method: frame.method!,
|
|
453
|
+
params: frame.params,
|
|
454
|
+
clientId: rec.id,
|
|
455
|
+
caller: callerOf(rec),
|
|
456
|
+
onAck: () => {
|
|
457
|
+
if (adm.settled || adm.acked) return;
|
|
458
|
+
adm.acked = true;
|
|
459
|
+
advance(rec, adm.seq);
|
|
460
|
+
scheduleFlush();
|
|
461
|
+
adm.release();
|
|
462
|
+
},
|
|
463
|
+
onSettle: (outcome) => settleWire(rec, adm, outcome),
|
|
464
|
+
}).catch((error: unknown) => {
|
|
465
|
+
if (!adm.settled) settleWire(rec, adm, { type: "rejected", error });
|
|
466
|
+
});
|
|
467
|
+
return released;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const message = (conn: Conn, data: string) => {
|
|
471
|
+
if (!conn.open || disposed || finished !== null) return;
|
|
472
|
+
const frame = parseFrame(data);
|
|
473
|
+
if (frame === null) {
|
|
474
|
+
closeConn(conn, 1008, "invalid frame");
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
inbox = inbox.then(() => admit(conn, frame));
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const connect = (
|
|
481
|
+
clientId: string,
|
|
482
|
+
socket: StatewireHostInternal.Socket,
|
|
483
|
+
): StatewireHostInternal.SocketConnection => {
|
|
484
|
+
const dead: StatewireHostInternal.SocketConnection = {
|
|
485
|
+
clientId,
|
|
486
|
+
message: () => {},
|
|
487
|
+
close: () => {},
|
|
488
|
+
};
|
|
489
|
+
const refuse = (code: number, reason: string) => {
|
|
490
|
+
try {
|
|
491
|
+
socket.close(code, reason);
|
|
492
|
+
} catch {
|
|
493
|
+
/* peer already gone */
|
|
494
|
+
}
|
|
495
|
+
return dead;
|
|
496
|
+
};
|
|
497
|
+
if (disposed) return refuse(1001, "disposed");
|
|
498
|
+
if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
|
|
499
|
+
flush();
|
|
500
|
+
if (finished !== null) {
|
|
501
|
+
try {
|
|
502
|
+
socket.send(JSON.stringify({ fin: finished }));
|
|
503
|
+
} catch {
|
|
504
|
+
/* peer already gone */
|
|
505
|
+
}
|
|
506
|
+
return refuse(1000, "finished");
|
|
507
|
+
}
|
|
508
|
+
const known = clients.has(clientId);
|
|
509
|
+
const rec = known
|
|
510
|
+
? clients.get(clientId)!
|
|
511
|
+
: (() => {
|
|
512
|
+
const created: ClientRec = {
|
|
513
|
+
id: clientId,
|
|
514
|
+
lastSeq: 0,
|
|
515
|
+
context: null,
|
|
516
|
+
results: new Map(),
|
|
517
|
+
inflight: new Map(),
|
|
518
|
+
};
|
|
519
|
+
clients.set(clientId, created);
|
|
520
|
+
return created;
|
|
521
|
+
})();
|
|
522
|
+
for (const other of [...conns]) {
|
|
523
|
+
if (other.clientId !== clientId) continue;
|
|
524
|
+
sendTo(other, JSON.stringify({ fin: { reason: "superseded" } }));
|
|
525
|
+
closeConn(other);
|
|
526
|
+
}
|
|
527
|
+
const conn: Conn = { clientId, socket, open: true, lastSentAt: 0 };
|
|
528
|
+
conns.add(conn);
|
|
529
|
+
ensureHeartbeat();
|
|
530
|
+
const { value, meta } = encodeValue(stateHost!.snapshot());
|
|
531
|
+
const snapshot: StatewireEnvelope = {
|
|
532
|
+
ops: [
|
|
533
|
+
{
|
|
534
|
+
op: "replace",
|
|
535
|
+
path: [],
|
|
536
|
+
value,
|
|
537
|
+
...(meta !== undefined && { meta }),
|
|
538
|
+
},
|
|
539
|
+
],
|
|
540
|
+
syn: { lastSeq: known ? rec.lastSeq : -1, lease: randomId() },
|
|
541
|
+
};
|
|
542
|
+
sendTo(conn, JSON.stringify(snapshot));
|
|
543
|
+
return {
|
|
544
|
+
clientId,
|
|
545
|
+
message: (data) => message(conn, data),
|
|
546
|
+
close: () => closeConn(conn),
|
|
547
|
+
};
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const finish = (fin: StatewireHost.Fin) => {
|
|
551
|
+
if (finished !== null || disposed) return;
|
|
552
|
+
finished = fin;
|
|
553
|
+
flush(fin);
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
connect,
|
|
558
|
+
finish,
|
|
559
|
+
attach,
|
|
560
|
+
resume: () => {
|
|
561
|
+
disposed = false;
|
|
562
|
+
stateHost?.setOnOp(recordOp);
|
|
563
|
+
emitHost?.setSink((event) => recordOp({ op: "event", value: event }));
|
|
564
|
+
},
|
|
565
|
+
suspend: () => {
|
|
566
|
+
disposed = true;
|
|
567
|
+
if (heartbeatTimer !== null) {
|
|
568
|
+
clearInterval(heartbeatTimer);
|
|
569
|
+
heartbeatTimer = null;
|
|
570
|
+
}
|
|
571
|
+
stateHost?.setOnOp(null);
|
|
572
|
+
emitHost?.setSink(null);
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const useSocketHost = (
|
|
578
|
+
transport: ResourceElement<Statewire>,
|
|
579
|
+
options?: StatewireHostInternal.SocketHostOptions,
|
|
580
|
+
): Pick<StatewireHostInternal.SocketHost, "connect" | "finish"> => {
|
|
581
|
+
const [h] = useState(() => createSocketHost(options));
|
|
582
|
+
const value = useResource(transport) as Statewire<unknown>;
|
|
583
|
+
h.attach(value);
|
|
584
|
+
useEffect(() => {
|
|
585
|
+
h.attach(value);
|
|
586
|
+
});
|
|
587
|
+
useEffect(() => {
|
|
588
|
+
h.resume();
|
|
589
|
+
return () => h.suspend();
|
|
590
|
+
}, [h]);
|
|
591
|
+
return h;
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Transport-agnostic WS binding over a statewire/host transport:
|
|
596
|
+
* feed it sockets (anything with `send`/`close`) and raw text frames, and it
|
|
597
|
+
* speaks the Statewire envelope protocol — snapshot-first attach with `syn`,
|
|
598
|
+
* delta/ack/res envelopes, per-client-id supersede, and `fin` on finish.
|
|
599
|
+
*/
|
|
600
|
+
export const StatewireSocketHost = (
|
|
601
|
+
transport: ResourceElement<Statewire>,
|
|
602
|
+
options?: StatewireHostInternal.SocketHostOptions,
|
|
603
|
+
): StatewireHostInternal.SocketHost => {
|
|
604
|
+
const SocketHostRoot = () => useSocketHost(transport, options);
|
|
605
|
+
const root = createTapRoot(SocketHostRoot);
|
|
606
|
+
let disposed = false;
|
|
607
|
+
return {
|
|
608
|
+
connect: (clientId, socket) =>
|
|
609
|
+
flushTapSync(() => root.getValue().connect(clientId, socket)),
|
|
610
|
+
finish: (fin) => flushTapSync(() => root.getValue().finish(fin)),
|
|
611
|
+
dispose: () => {
|
|
612
|
+
if (disposed) return;
|
|
613
|
+
disposed = true;
|
|
614
|
+
flushTapSync(() => root.getValue().finish({ reason: "evicted" }));
|
|
615
|
+
root.unmount();
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { StatewireHost } from "./host";
|
|
2
|
+
|
|
3
|
+
export { StatewireSocketHost } from "./host/socket";
|
|
4
|
+
export { clientIdFromUrl, textFrameOrClose } from "./host/adapter";
|
|
5
|
+
export namespace StatewireHostInternal {
|
|
6
|
+
export type Socket = {
|
|
7
|
+
send: (data: string) => void;
|
|
8
|
+
close: (code?: number, reason?: string) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type SocketConnection = {
|
|
12
|
+
readonly clientId: string;
|
|
13
|
+
message: (data: string) => void;
|
|
14
|
+
close: () => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type SocketHost = {
|
|
18
|
+
connect: (clientId: string, socket: Socket) => SocketConnection;
|
|
19
|
+
finish: (fin: StatewireHost.Fin) => void;
|
|
20
|
+
dispose: () => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SocketHostOptions = {
|
|
24
|
+
heartbeatIntervalMs?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type Fin = StatewireHost.Fin;
|
|
28
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Statewire } from "./types";
|
|
2
|
+
import type { ServerOp } from "./host/draft";
|
|
3
|
+
|
|
4
|
+
export { StatewireReject } from "./host/engine";
|
|
5
|
+
export { append } from "./host/draft";
|
|
6
|
+
export {
|
|
7
|
+
CommandExecution,
|
|
8
|
+
getStateHost,
|
|
9
|
+
useStatewireState,
|
|
10
|
+
useStatewireCommands,
|
|
11
|
+
useStatewireEmit,
|
|
12
|
+
} from "./host/host";
|
|
13
|
+
|
|
14
|
+
declare const executionResult: unique symbol;
|
|
15
|
+
|
|
16
|
+
type UnwrapExecution<V> = V extends StatewireHost.Execution<infer T> ? T : V;
|
|
17
|
+
|
|
18
|
+
export namespace StatewireHost {
|
|
19
|
+
export type StateCtl<TState = unknown> = {
|
|
20
|
+
/** Apply server-vocabulary ops (root replace = whole-state swap). */
|
|
21
|
+
apply: (ops: readonly Statewire.Op[]) => void;
|
|
22
|
+
/** The committed plain state — immutably replaced per op, so a captured value is a stable snapshot. */
|
|
23
|
+
snapshot: () => TState;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type StateHost = Statewire.StateSource & {
|
|
27
|
+
/** Synchronous, per locally-recorded op; the host owns batching. */
|
|
28
|
+
setOnOp: (listener: ((op: ServerOp) => void) | null) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type StateSource = Statewire.StateSource;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Handle a command handler returns to keep the command open past its own
|
|
35
|
+
* return: `ack()` releases the client's optimistic overlay / ack watermark,
|
|
36
|
+
* `resolve`/`reject` settle the command (and imply ack). `ack` is idempotent
|
|
37
|
+
* and a no-op after settle.
|
|
38
|
+
*/
|
|
39
|
+
export type Execution<T = void> = {
|
|
40
|
+
readonly [executionResult]: (value: T) => void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type ExecutionResolvers<T = void> = {
|
|
44
|
+
ack: () => void;
|
|
45
|
+
resolve: (value: T) => void;
|
|
46
|
+
reject: (error: unknown) => void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The submitting client, bound as `this` of a command handler. Holdable
|
|
51
|
+
* across a long run: `context` reads the client's current ambient context
|
|
52
|
+
* live (null until the client sends one), `isConnected` reports whether the
|
|
53
|
+
* client has an active stream attachment.
|
|
54
|
+
*/
|
|
55
|
+
export type Caller = {
|
|
56
|
+
readonly context: Record<string, unknown> | null;
|
|
57
|
+
readonly isConnected: boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type HandledCommands<THandlers extends Statewire.Commands> = {
|
|
61
|
+
[K in keyof THandlers]: (
|
|
62
|
+
...args: Parameters<THandlers[K]>
|
|
63
|
+
) => UnwrapExecution<Awaited<ReturnType<THandlers[K]>>>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type Fin = {
|
|
67
|
+
reason: "evicted" | "error" | "gone" | "superseded" | (string & {});
|
|
68
|
+
message?: string;
|
|
69
|
+
payload?: unknown;
|
|
70
|
+
meta?: unknown;
|
|
71
|
+
};
|
|
72
|
+
}
|