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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -1
  3. package/dist/client/changes.d.ts +7 -0
  4. package/dist/client/changes.d.ts.map +1 -0
  5. package/dist/client/changes.js +32 -0
  6. package/dist/client/changes.js.map +1 -0
  7. package/dist/client/connection.d.ts +84 -0
  8. package/dist/client/connection.d.ts.map +1 -0
  9. package/dist/client/connection.js +204 -0
  10. package/dist/client/connection.js.map +1 -0
  11. package/dist/client/core.d.ts +18 -0
  12. package/dist/client/core.d.ts.map +1 -0
  13. package/dist/client/core.js +269 -0
  14. package/dist/client/core.js.map +1 -0
  15. package/dist/client/optimistic.d.ts +10 -0
  16. package/dist/client/optimistic.d.ts.map +1 -0
  17. package/dist/client/optimistic.js +128 -0
  18. package/dist/client/optimistic.js.map +1 -0
  19. package/dist/client/session.d.ts +88 -0
  20. package/dist/client/session.d.ts.map +1 -0
  21. package/dist/client/session.js +509 -0
  22. package/dist/client/session.js.map +1 -0
  23. package/dist/client/sessionStore.d.ts +16 -0
  24. package/dist/client/sessionStore.d.ts.map +1 -0
  25. package/dist/client/sessionStore.js +14 -0
  26. package/dist/client/sessionStore.js.map +1 -0
  27. package/dist/client/timing.d.ts +10 -0
  28. package/dist/client/timing.d.ts.map +1 -0
  29. package/dist/client/timing.js +11 -0
  30. package/dist/client/timing.js.map +1 -0
  31. package/dist/host/draft.d.ts +14 -0
  32. package/dist/host/draft.d.ts.map +1 -0
  33. package/dist/host/draft.js +124 -0
  34. package/dist/host/draft.js.map +1 -0
  35. package/dist/host/engine.d.ts +14 -0
  36. package/dist/host/engine.d.ts.map +1 -0
  37. package/dist/host/engine.js +17 -0
  38. package/dist/host/engine.js.map +1 -0
  39. package/dist/host/host.d.ts +79 -0
  40. package/dist/host/host.d.ts.map +1 -0
  41. package/dist/host/host.js +303 -0
  42. package/dist/host/host.js.map +1 -0
  43. package/dist/index.d.ts +28 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +6 -0
  46. package/dist/server.d.ts +15 -0
  47. package/dist/server.d.ts.map +1 -0
  48. package/dist/server.js +3 -0
  49. package/dist/statewire.d.ts +20 -0
  50. package/dist/statewire.d.ts.map +1 -0
  51. package/dist/statewire.js +65 -0
  52. package/dist/statewire.js.map +1 -0
  53. package/dist/transports/remote.d.ts +19 -0
  54. package/dist/transports/remote.d.ts.map +1 -0
  55. package/dist/transports/remote.js +40 -0
  56. package/dist/transports/remote.js.map +1 -0
  57. package/dist/transports/sse-parser.d.ts +5 -0
  58. package/dist/transports/sse-parser.d.ts.map +1 -0
  59. package/dist/transports/sse-parser.js +26 -0
  60. package/dist/transports/sse-parser.js.map +1 -0
  61. package/dist/transports/sse.d.ts +74 -0
  62. package/dist/transports/sse.d.ts.map +1 -0
  63. package/dist/transports/sse.js +136 -0
  64. package/dist/transports/sse.js.map +1 -0
  65. package/dist/transports/testing.d.ts +31 -0
  66. package/dist/transports/testing.d.ts.map +1 -0
  67. package/dist/transports/testing.js +121 -0
  68. package/dist/transports/testing.js.map +1 -0
  69. package/dist/transports/ws.d.ts +18 -0
  70. package/dist/transports/ws.d.ts.map +1 -0
  71. package/dist/transports/ws.js +172 -0
  72. package/dist/transports/ws.js.map +1 -0
  73. package/dist/types.d.ts +107 -0
  74. package/dist/types.d.ts.map +1 -0
  75. package/dist/types.js +24 -0
  76. package/dist/types.js.map +1 -0
  77. package/dist/wire/meta.d.ts +22 -0
  78. package/dist/wire/meta.d.ts.map +1 -0
  79. package/dist/wire/meta.js +73 -0
  80. package/dist/wire/meta.js.map +1 -0
  81. package/dist/wire/ops.d.ts +40 -0
  82. package/dist/wire/ops.d.ts.map +1 -0
  83. package/dist/wire/ops.js +125 -0
  84. package/dist/wire/ops.js.map +1 -0
  85. package/package.json +49 -6
  86. package/src/client/changes.ts +48 -0
  87. package/src/client/connection.ts +327 -0
  88. package/src/client/core.ts +346 -0
  89. package/src/client/optimistic.ts +0 -0
  90. package/src/client/session.ts +679 -0
  91. package/src/client/sessionStore.ts +23 -0
  92. package/src/client/timing.ts +8 -0
  93. package/src/host/draft.ts +155 -0
  94. package/src/host/engine.ts +13 -0
  95. package/src/host/host.ts +481 -0
  96. package/src/index.ts +65 -0
  97. package/src/server.ts +32 -0
  98. package/src/statewire.ts +107 -0
  99. package/src/transports/remote.ts +72 -0
  100. package/src/transports/sse-parser.ts +24 -0
  101. package/src/transports/sse.ts +280 -0
  102. package/src/transports/testing.ts +155 -0
  103. package/src/transports/ws.ts +272 -0
  104. package/src/types.ts +169 -0
  105. package/src/wire/meta.ts +106 -0
  106. package/src/wire/ops.ts +201 -0
  107. package/index.js +0 -1
@@ -0,0 +1,155 @@
1
+ import type { StatewireOp, StatewirePath } from "../wire/ops";
2
+
3
+ export type ServerOp = Exclude<StatewireOp, { op: "event" }>;
4
+
5
+ export type DraftHost = {
6
+ root: () => unknown;
7
+ record: (op: ServerOp) => void;
8
+ };
9
+
10
+ export const frozen = (value: unknown): unknown => {
11
+ if (value instanceof Date) return new Date(value.getTime());
12
+ if (Array.isArray(value)) {
13
+ return value.map((item) => frozen(item) ?? null);
14
+ }
15
+ if (typeof value === "object" && value !== null) {
16
+ const out: Record<string, unknown> = {};
17
+ for (const key of Object.keys(value)) {
18
+ const item = frozen((value as Record<string, unknown>)[key]);
19
+ if (item !== undefined) out[key] = item;
20
+ }
21
+ return out;
22
+ }
23
+ return typeof value === "function" ? undefined : value;
24
+ };
25
+
26
+ const lookup = (root: unknown, path: StatewirePath): unknown => {
27
+ let node = root;
28
+ for (const key of path) {
29
+ node = (node as Record<string | number, unknown>)[key];
30
+ }
31
+ return node;
32
+ };
33
+
34
+ const isIndex = (key: string) => /^(0|[1-9]\d*)$/.test(key);
35
+
36
+ const invalid = (path: StatewirePath, message: string) =>
37
+ new Error(`statewire: ${message} at path: ${path.join(".")}`);
38
+
39
+ export const createStateDraft = <T>(
40
+ host: DraftHost,
41
+ path: StatewirePath = [],
42
+ ): T => {
43
+ const node = () => lookup(host.root(), path);
44
+
45
+ const assign = (
46
+ container: Record<string | number, unknown>,
47
+ key: string | number,
48
+ value: unknown,
49
+ ) => {
50
+ const old = Object.hasOwn(container, key) ? container[key] : undefined;
51
+ if (old === value) return;
52
+ if (
53
+ typeof old === "string" &&
54
+ typeof value === "string" &&
55
+ value.startsWith(old)
56
+ ) {
57
+ host.record({
58
+ op: "append",
59
+ path: [...path, key],
60
+ value: value.slice(old.length),
61
+ });
62
+ return;
63
+ }
64
+ host.record({ op: "replace", path: [...path, key], value });
65
+ };
66
+
67
+ return new Proxy(node() as object, {
68
+ get: (_target, prop) => {
69
+ const current = node() as Record<string | number, unknown>;
70
+ if (typeof prop === "symbol") {
71
+ return (current as unknown as Record<symbol, unknown>)[prop];
72
+ }
73
+ const key = Array.isArray(current) && isIndex(prop) ? Number(prop) : prop;
74
+ const value = current[key];
75
+ if (typeof value === "object" && value !== null) {
76
+ return createStateDraft(host, [...path, key]);
77
+ }
78
+ return value;
79
+ },
80
+ set: (_target, prop, rawValue) => {
81
+ if (typeof prop === "symbol") return false;
82
+ const current = node();
83
+ const value = frozen(rawValue);
84
+ if (Array.isArray(current)) {
85
+ if (prop === "length") {
86
+ const next = Number(value);
87
+ if (!Number.isInteger(next) || next < 0 || next > current.length) {
88
+ throw invalid(path, `cannot set list length to ${String(value)}`);
89
+ }
90
+ for (let i = current.length - 1; i >= next; i--) {
91
+ host.record({ op: "remove", path: [...path, i] });
92
+ }
93
+ return true;
94
+ }
95
+ if (!isIndex(prop)) {
96
+ throw invalid(path, `cannot set key "${prop}" on a list`);
97
+ }
98
+ const index = Number(prop);
99
+ if (index > current.length) {
100
+ throw invalid(path, `list index ${index} out of range`);
101
+ }
102
+ if (index === current.length) {
103
+ host.record({ op: "add", path: [...path, index], value });
104
+ return true;
105
+ }
106
+ assign(
107
+ current as unknown as Record<string | number, unknown>,
108
+ index,
109
+ value,
110
+ );
111
+ return true;
112
+ }
113
+ assign(current as Record<string, unknown>, prop, value);
114
+ return true;
115
+ },
116
+ deleteProperty: (_target, prop) => {
117
+ if (typeof prop === "symbol") return false;
118
+ const current = node();
119
+ if (Array.isArray(current)) {
120
+ if (!isIndex(prop) || Number(prop) >= current.length) return true;
121
+ host.record({ op: "remove", path: [...path, Number(prop)] });
122
+ return true;
123
+ }
124
+ if (!Object.hasOwn(current as object, prop)) return true;
125
+ host.record({ op: "remove", path: [...path, prop] });
126
+ return true;
127
+ },
128
+ defineProperty: () => {
129
+ throw new Error(
130
+ "statewire: defineProperty is not supported on a state draft",
131
+ );
132
+ },
133
+ setPrototypeOf: () => {
134
+ throw new Error(
135
+ "statewire: setPrototypeOf is not supported on a state draft",
136
+ );
137
+ },
138
+ preventExtensions: () => {
139
+ throw new Error(
140
+ "statewire: preventExtensions is not supported on a state draft",
141
+ );
142
+ },
143
+ has: (_target, prop) => Reflect.has(node() as object, prop),
144
+ ownKeys: () => Reflect.ownKeys(node() as object),
145
+ getOwnPropertyDescriptor: (_target, prop) => {
146
+ const current = node() as object;
147
+ const desc = Reflect.getOwnPropertyDescriptor(current, prop);
148
+ if (!desc) return undefined;
149
+ return {
150
+ ...desc,
151
+ configurable: !Array.isArray(current) || prop !== "length",
152
+ };
153
+ },
154
+ }) as T;
155
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Definitive handler rejection: the command settles rejected and
3
+ * message/payload travel to the client's rejected promise.
4
+ */
5
+ export class StatewireReject extends Error {
6
+ readonly payload?: unknown;
7
+
8
+ constructor(message: string, options?: { payload?: unknown }) {
9
+ super(message);
10
+ this.name = "StatewireReject";
11
+ if (options?.payload !== undefined) this.payload = options.payload;
12
+ }
13
+ }
@@ -0,0 +1,481 @@
1
+ import { useEffect, useState } from "react";
2
+ import { applyOp, applyOps } from "../wire/ops";
3
+ import type { StatewireOp, StatewirePath } from "../wire/ops";
4
+ import { StatewireReject } from "./engine";
5
+ import { createStateDraft, frozen } from "./draft";
6
+ import type { ServerOp } from "./draft";
7
+ import { StatewireSendError } from "../types";
8
+ import type {
9
+ StatewireCommands,
10
+ StatewireCommandsProxy,
11
+ StatewireStateSource,
12
+ } from "../types";
13
+
14
+ const isDev = () =>
15
+ typeof process === "undefined" || process.env["NODE_ENV"] !== "production";
16
+
17
+ // ─── state ──────────────────────────────────────────────
18
+
19
+ export type StatewireStateCtl = {
20
+ /** Apply server-vocabulary ops (root replace = whole-state swap). */
21
+ apply: (ops: readonly StatewireOp[]) => void;
22
+ };
23
+
24
+ export type StateHost = StatewireStateSource & {
25
+ /** Synchronous, per locally-recorded op; the host owns batching. */
26
+ setOnOp: (listener: ((op: ServerOp) => void) | null) => void;
27
+ };
28
+
29
+ const stateHosts = new WeakMap<object, StateHost>();
30
+
31
+ export const getStateHost = (state: object): StateHost | undefined =>
32
+ stateHosts.get(state);
33
+
34
+ type StateCell = {
35
+ current: unknown;
36
+ draft: object | null;
37
+ changed: StatewirePath[];
38
+ onOp: ((op: ServerOp) => void) | null;
39
+ onChange: (() => void) | null;
40
+ ctl: StatewireStateCtl;
41
+ host: StateHost;
42
+ bump: () => void;
43
+ };
44
+
45
+ export const useStatewireState = <TState>(
46
+ init: () => TState,
47
+ ): readonly [TState, StatewireStateCtl] => {
48
+ const [, setVersion] = useState(0);
49
+ const [cell] = useState((): StateCell => {
50
+ const c: StateCell = {
51
+ current: frozen(init()),
52
+ draft: null,
53
+ changed: [[]],
54
+ onOp: null,
55
+ onChange: null,
56
+ bump: () => setVersion((v) => v + 1),
57
+ ctl: {
58
+ apply: (ops) => {
59
+ const result = applyOps(c.current, ops);
60
+ if (result.events.length > 0) {
61
+ throw new Error(
62
+ "statewire: event ops do not apply to state — route them to emit",
63
+ );
64
+ }
65
+ c.current = result.state;
66
+ c.changed.push(...result.changedPaths);
67
+ c.bump();
68
+ c.onChange?.();
69
+ },
70
+ },
71
+ host: {
72
+ root: () => c.current,
73
+ setOnOp: (listener) => {
74
+ c.onOp = listener;
75
+ },
76
+ setOnChange: (listener) => {
77
+ c.onChange = listener;
78
+ },
79
+ takeChanged: () => {
80
+ const changed = c.changed;
81
+ c.changed = [];
82
+ return changed;
83
+ },
84
+ },
85
+ };
86
+ return c;
87
+ });
88
+
89
+ let value = cell.current;
90
+ if (typeof value === "object" && value !== null) {
91
+ cell.draft ??= createStateDraft<object>({
92
+ root: () => cell.current,
93
+ record: (op) => {
94
+ const applied = applyOp(cell.current, op);
95
+ cell.current = applied.state;
96
+ cell.changed.push(...applied.changedPaths);
97
+ cell.onOp?.(op);
98
+ cell.bump();
99
+ cell.onChange?.();
100
+ },
101
+ });
102
+ stateHosts.set(cell.draft, cell.host);
103
+ value = cell.draft;
104
+ }
105
+ return [value as TState, cell.ctl] as const;
106
+ };
107
+
108
+ // ─── commands ───────────────────────────────────────────
109
+
110
+ type ExecutionSettled =
111
+ | { type: "resolved"; value: unknown }
112
+ | { type: "rejected"; error: unknown };
113
+
114
+ type ExecutionBind = {
115
+ onAck: () => void;
116
+ onSettle: (settled: ExecutionSettled) => void;
117
+ };
118
+
119
+ type ExecutionState = {
120
+ acked: boolean;
121
+ settled: ExecutionSettled | null;
122
+ bind: ExecutionBind | null;
123
+ };
124
+
125
+ const executionStates = new WeakMap<object, ExecutionState>();
126
+
127
+ declare const executionResult: unique symbol;
128
+
129
+ /**
130
+ * Handle a command handler returns to keep the command open past its own
131
+ * return: `ack()` releases the client's optimistic overlay / ack watermark,
132
+ * `resolve`/`reject` settle the command (and imply ack). `ack` is idempotent
133
+ * and a no-op after settle.
134
+ */
135
+ export type CommandExecution<T = void> = {
136
+ readonly [executionResult]: (value: T) => void;
137
+ };
138
+
139
+ export type CommandExecutionResolvers<T = void> = {
140
+ ack: () => void;
141
+ resolve: (value: T) => void;
142
+ reject: (error: unknown) => void;
143
+ };
144
+
145
+ /**
146
+ * `CommandExecution(executor)` runs the (possibly async) `executor({ ack })`
147
+ * immediately: its return value resolves the execution, a throw/rejection
148
+ * rejects it; `ack()` releases the client's ack watermark before the executor
149
+ * finishes. `CommandExecution.withResolvers()` returns `[execution,
150
+ * { ack, resolve, reject }]` instead — settle them from your own spawned work.
151
+ */
152
+ export const CommandExecution = Object.assign(
153
+ <T = void>(
154
+ executor: (resolvers: {
155
+ ack: () => void;
156
+ }) => T | void | PromiseLike<T | void>,
157
+ ): CommandExecution<T> => {
158
+ const state: ExecutionState = { acked: false, settled: null, bind: null };
159
+ const execution = Object.freeze({}) as unknown as CommandExecution<T>;
160
+ executionStates.set(execution, state);
161
+ const settle = (settled: ExecutionSettled) => {
162
+ if (state.settled) return;
163
+ state.settled = settled;
164
+ state.acked = true;
165
+ state.bind?.onSettle(settled);
166
+ };
167
+ const ack = () => {
168
+ if (state.acked) return;
169
+ state.acked = true;
170
+ state.bind?.onAck();
171
+ };
172
+ void (async () => {
173
+ try {
174
+ settle({ type: "resolved", value: await executor({ ack }) });
175
+ } catch (error) {
176
+ settle({ type: "rejected", error });
177
+ }
178
+ })();
179
+ return execution;
180
+ },
181
+ {
182
+ withResolvers: <T = void>(): [
183
+ CommandExecution<T>,
184
+ CommandExecutionResolvers<T>,
185
+ ] => {
186
+ let resolve!: (value: T) => void;
187
+ let reject!: (error: unknown) => void;
188
+ const promise = new Promise<T>((res, rej) => {
189
+ resolve = res;
190
+ reject = rej;
191
+ });
192
+ let ack!: () => void;
193
+ const execution = CommandExecution<T>(({ ack: a }) => {
194
+ ack = a;
195
+ return promise;
196
+ });
197
+ return [execution, { ack: () => ack(), resolve, reject }];
198
+ },
199
+ },
200
+ );
201
+
202
+ export type CommandsInvocation = {
203
+ readonly method: string;
204
+ readonly params: readonly unknown[];
205
+ readonly ts: number;
206
+ readonly seq: number;
207
+ readonly clientId?: string;
208
+ };
209
+
210
+ export type CommandsOutcome =
211
+ | { type: "accepted"; payload?: unknown }
212
+ | { type: "rejected"; error: unknown };
213
+
214
+ export type CommandsListener = {
215
+ onStart: (inv: CommandsInvocation) => void;
216
+ onPosted?: (inv: CommandsInvocation) => void;
217
+ onAck: (inv: CommandsInvocation) => void;
218
+ /** Canonical state now covers the invocation's effect — drop any overlay. */
219
+ onCovered?: (inv: CommandsInvocation) => void;
220
+ onSettle: (inv: CommandsInvocation, outcome: CommandsOutcome) => void;
221
+ };
222
+
223
+ export type CommandsHost = {
224
+ setListener: (listener: CommandsListener | null) => void;
225
+ };
226
+
227
+ const commandsHosts = new WeakMap<object, CommandsHost>();
228
+
229
+ export const getCommandsHost = (commands: object): CommandsHost | undefined =>
230
+ commandsHosts.get(commands);
231
+
232
+ export const bindCommandsHost = (commands: object, host: CommandsHost) => {
233
+ commandsHosts.set(commands, host);
234
+ };
235
+
236
+ type QueuedCall = {
237
+ method: string;
238
+ params: readonly unknown[];
239
+ ts: number;
240
+ resolve: (value: unknown) => void;
241
+ reject: (error: unknown) => void;
242
+ };
243
+
244
+ type ActiveInvocation = CommandsInvocation & { acked: boolean };
245
+
246
+ type CommandsCell = {
247
+ handlers: Record<string, (...args: never[]) => unknown> | null;
248
+ queue: QueuedCall[];
249
+ running: boolean;
250
+ listener: CommandsListener | null;
251
+ seq: number;
252
+ unmounted: boolean;
253
+ stallTimer: ReturnType<typeof setTimeout> | null;
254
+ };
255
+
256
+ const STALL_WARN_MS = 5000;
257
+
258
+ const toSendError = (error: unknown): StatewireSendError =>
259
+ error instanceof StatewireSendError
260
+ ? error
261
+ : new StatewireSendError({
262
+ fate: "rejected",
263
+ cause: "server",
264
+ message: error instanceof Error ? error.message : String(error),
265
+ ...(error instanceof StatewireReject &&
266
+ error.payload !== undefined && { payload: error.payload }),
267
+ });
268
+
269
+ const ackInvocation = (cell: CommandsCell, inv: ActiveInvocation) => {
270
+ if (inv.acked) return;
271
+ inv.acked = true;
272
+ cell.listener?.onAck(inv);
273
+ };
274
+
275
+ const settleInvocation = (
276
+ cell: CommandsCell,
277
+ inv: ActiveInvocation,
278
+ call: QueuedCall,
279
+ settled: ExecutionSettled,
280
+ ) => {
281
+ ackInvocation(cell, inv);
282
+ if (settled.type === "resolved") {
283
+ const outcome: CommandsOutcome =
284
+ settled.value === undefined
285
+ ? { type: "accepted" }
286
+ : { type: "accepted", payload: frozen(settled.value) };
287
+ cell.listener?.onSettle(inv, outcome);
288
+ call.resolve(outcome.payload);
289
+ } else {
290
+ const error = toSendError(settled.error);
291
+ cell.listener?.onSettle(inv, { type: "rejected", error });
292
+ call.reject(error);
293
+ }
294
+ };
295
+
296
+ const pump = (cell: CommandsCell) => {
297
+ if (cell.running || !cell.handlers) {
298
+ watchStall(cell);
299
+ return;
300
+ }
301
+ const call = cell.queue.shift();
302
+ if (!call) return;
303
+ const handler = cell.handlers[call.method];
304
+ if (handler === undefined) {
305
+ call.reject(
306
+ new StatewireSendError({ fate: "unknown-command", cause: "server" }),
307
+ );
308
+ pump(cell);
309
+ return;
310
+ }
311
+ const inv: ActiveInvocation = {
312
+ method: call.method,
313
+ params: call.params,
314
+ ts: call.ts,
315
+ seq: ++cell.seq,
316
+ acked: false,
317
+ };
318
+ cell.running = true;
319
+ cell.listener?.onStart(inv);
320
+ cell.listener?.onPosted?.(inv);
321
+ cell.listener?.onCovered?.(inv);
322
+ void (async () => {
323
+ try {
324
+ const value = await (handler as (...args: readonly unknown[]) => unknown)(
325
+ ...call.params,
326
+ );
327
+ const execution =
328
+ typeof value === "object" && value !== null
329
+ ? executionStates.get(value)
330
+ : undefined;
331
+ if (execution) {
332
+ if (execution.acked) ackInvocation(cell, inv);
333
+ if (execution.settled) {
334
+ settleInvocation(cell, inv, call, execution.settled);
335
+ } else {
336
+ execution.bind = {
337
+ onAck: () => ackInvocation(cell, inv),
338
+ onSettle: (settled) => settleInvocation(cell, inv, call, settled),
339
+ };
340
+ }
341
+ } else {
342
+ settleInvocation(cell, inv, call, { type: "resolved", value });
343
+ }
344
+ } catch (error) {
345
+ settleInvocation(cell, inv, call, { type: "rejected", error });
346
+ } finally {
347
+ cell.running = false;
348
+ pump(cell);
349
+ }
350
+ })();
351
+ };
352
+
353
+ const watchStall = (cell: CommandsCell) => {
354
+ if (!isDev() || cell.stallTimer !== null || cell.queue.length === 0) return;
355
+ if (cell.handlers) return;
356
+ cell.stallTimer = setTimeout(() => {
357
+ cell.stallTimer = null;
358
+ if (cell.handlers === null && cell.queue.length > 0 && !cell.unmounted) {
359
+ console.warn(
360
+ `statewire: ${cell.queue.length} buffered command(s) waiting over ${STALL_WARN_MS}ms — did the host forget to arm useStatewireCommands?`,
361
+ );
362
+ }
363
+ }, STALL_WARN_MS);
364
+ };
365
+
366
+ type UnwrapExecution<V> = V extends CommandExecution<infer T> ? T : V;
367
+
368
+ export type StatewireHandledCommands<THandlers extends StatewireCommands> = {
369
+ [K in keyof THandlers]: (
370
+ ...args: Parameters<THandlers[K]>
371
+ ) => UnwrapExecution<Awaited<ReturnType<THandlers[K]>>>;
372
+ };
373
+
374
+ export const useStatewireCommands = <THandlers extends StatewireCommands>(
375
+ commands: THandlers | undefined | null | false,
376
+ ): StatewireCommandsProxy<StatewireHandledCommands<THandlers>> => {
377
+ const [built] = useState(() => {
378
+ const cell: CommandsCell = {
379
+ handlers: null,
380
+ queue: [],
381
+ running: false,
382
+ listener: null,
383
+ seq: 0,
384
+ unmounted: false,
385
+ stallTimer: null,
386
+ };
387
+ const memo = new Map<string, (...params: unknown[]) => Promise<unknown>>();
388
+ const proxy = new Proxy(
389
+ {} as StatewireCommandsProxy<StatewireHandledCommands<THandlers>>,
390
+ {
391
+ get: (_target, method) => {
392
+ if (typeof method !== "string") return undefined;
393
+ let fn = memo.get(method);
394
+ if (fn === undefined) {
395
+ fn = (...params: unknown[]) =>
396
+ new Promise((resolve, reject) => {
397
+ if (cell.unmounted) {
398
+ reject(new Error("statewire: commands called after unmount"));
399
+ return;
400
+ }
401
+ cell.queue.push({
402
+ method,
403
+ params,
404
+ ts: Date.now(),
405
+ resolve,
406
+ reject,
407
+ });
408
+ pump(cell);
409
+ });
410
+ memo.set(method, fn);
411
+ }
412
+ return fn;
413
+ },
414
+ },
415
+ );
416
+ commandsHosts.set(proxy, {
417
+ setListener: (listener) => {
418
+ cell.listener = listener;
419
+ },
420
+ });
421
+ return { cell, proxy };
422
+ });
423
+
424
+ useEffect(() => {
425
+ const { cell } = built;
426
+ cell.unmounted = false;
427
+ return () => {
428
+ cell.unmounted = true;
429
+ cell.handlers = null;
430
+ if (cell.stallTimer !== null) {
431
+ clearTimeout(cell.stallTimer);
432
+ cell.stallTimer = null;
433
+ }
434
+ queueMicrotask(() => {
435
+ if (!cell.unmounted) return;
436
+ const queued = cell.queue;
437
+ cell.queue = [];
438
+ for (const call of queued) {
439
+ call.reject(new Error("statewire: unmounted"));
440
+ }
441
+ });
442
+ };
443
+ }, [built]);
444
+
445
+ useEffect(() => {
446
+ built.cell.handlers = commands || null;
447
+ pump(built.cell);
448
+ });
449
+
450
+ return built.proxy;
451
+ };
452
+
453
+ // ─── emit ───────────────────────────────────────────────
454
+
455
+ export type EmitHost = {
456
+ setSink: (sink: ((event: unknown) => void) | null) => void;
457
+ };
458
+
459
+ const emitHosts = new WeakMap<object, EmitHost>();
460
+
461
+ export const getEmitHost = (emit: object): EmitHost | undefined =>
462
+ emitHosts.get(emit);
463
+
464
+ export const useStatewireEmit = <TEvent>(): ((event: TEvent) => void) => {
465
+ const [built] = useState(() => {
466
+ const cell = { sink: null as ((event: unknown) => void) | null };
467
+ const emit = (event: TEvent) => {
468
+ if (!cell.sink) {
469
+ throw new Error("statewire: emit before a host bound the event sink");
470
+ }
471
+ cell.sink(frozen(event));
472
+ };
473
+ emitHosts.set(emit, {
474
+ setSink: (sink) => {
475
+ cell.sink = sink;
476
+ },
477
+ });
478
+ return emit;
479
+ });
480
+ return built;
481
+ };
package/src/index.ts ADDED
@@ -0,0 +1,65 @@
1
+ import type { StatewireStateSource, StatewireTransport } from "./types";
2
+ import type { StatewireOp } from "./wire/ops";
3
+ import type {
4
+ SendFailure as StatewireSendFailure,
5
+ StatewireChanges,
6
+ StatewireCommand,
7
+ StatewireCommands,
8
+ StatewireCommandsProxy,
9
+ StatewireCommandUpdate,
10
+ StatewireHelpers,
11
+ StatewireOptimistic,
12
+ StatewireOptimisticContext,
13
+ StatewireOptions,
14
+ } from "./types";
15
+ import type {
16
+ StatewireSSEOptions,
17
+ StatewireConnection,
18
+ } from "./transports/sse";
19
+ import type { StatewireWSOptions } from "./transports/ws";
20
+ import { Statewire } from "./statewire";
21
+
22
+ export { Statewire };
23
+ export { useStatewire, StatewireResource } from "./statewire";
24
+ export { applyOps } from "./wire/ops";
25
+ export { StatewireSSE } from "./transports/sse";
26
+ export { StatewireWS } from "./transports/ws";
27
+ export { StatewireSendError } from "./types";
28
+
29
+ export namespace Statewire {
30
+ export type Connection = StatewireConnection;
31
+ export type ConnectionStatus = StatewireConnection["status"];
32
+ export type Transport<
33
+ TState = any,
34
+ TCommands extends StatewireCommands = StatewireCommands,
35
+ TEvent = any,
36
+ > = StatewireTransport<TState, TCommands, TEvent>;
37
+ export type StateSource = StatewireStateSource;
38
+ export type SSEOptions = StatewireSSEOptions;
39
+ export type WSOptions = StatewireWSOptions;
40
+ export type Options<
41
+ TState,
42
+ TCommands extends StatewireCommands = StatewireCommands,
43
+ TEvent = any,
44
+ > = StatewireOptions<TState, TCommands, TEvent>;
45
+ export type Helpers<
46
+ TState,
47
+ TCommands extends StatewireCommands = StatewireCommands,
48
+ > = StatewireHelpers<TState, TCommands>;
49
+ export type Commands = StatewireCommands;
50
+ export type Command<TCommands extends StatewireCommands = StatewireCommands> =
51
+ StatewireCommand<TCommands>;
52
+ export type CommandsProxy<TCommands extends StatewireCommands> =
53
+ StatewireCommandsProxy<TCommands>;
54
+ export type CommandUpdate<
55
+ TCommands extends StatewireCommands = StatewireCommands,
56
+ > = StatewireCommandUpdate<TCommands>;
57
+ export type Optimistic<
58
+ TState,
59
+ TCommands extends StatewireCommands = StatewireCommands,
60
+ > = StatewireOptimistic<TState, TCommands>;
61
+ export type OptimisticContext = StatewireOptimisticContext;
62
+ export type SendFailure = StatewireSendFailure;
63
+ export type Changes = StatewireChanges;
64
+ export type Op = StatewireOp;
65
+ }