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,482 @@
1
+ import { useEffect, useState } from "react";
2
+ import { applyOps, sanitizeValue } from "../wire/ops";
3
+ import type { StatewirePath } from "../wire/ops";
4
+ import { StatewireReject } from "./engine";
5
+ import { createStateDraft } from "./draft";
6
+ import { createMethodProxy } from "../util/methodProxy";
7
+ import type { ServerOp, StateDraft } from "./draft";
8
+ import { StatewireSendError } from "../types";
9
+ import type { Statewire } from "../types";
10
+ import type { StatewireHost } from "../host";
11
+
12
+ const isDev = () =>
13
+ typeof process === "undefined" || process.env["NODE_ENV"] !== "production";
14
+
15
+ // ─── state ──────────────────────────────────────────────
16
+
17
+ const stateHosts = new WeakMap<object, StatewireHost.StateHost>();
18
+
19
+ /** Host-side accessor for a `useStatewireState` state object's source (`snapshot()` reads the committed state). */
20
+ export const getStateHost = (
21
+ state: object,
22
+ ): StatewireHost.StateHost | undefined => stateHosts.get(state);
23
+
24
+ type StateCell = {
25
+ current: unknown;
26
+ draft: StateDraft<object> | null;
27
+ changed: StatewirePath[];
28
+ onOp: ((op: ServerOp) => void) | null;
29
+ onChange: (() => void) | null;
30
+ ctl: StatewireHost.StateCtl;
31
+ host: StatewireHost.StateHost;
32
+ bump: () => void;
33
+ };
34
+
35
+ export const useStatewireState = <TState>(
36
+ init: () => TState,
37
+ ): readonly [TState, StatewireHost.StateCtl<TState>] => {
38
+ const [, setVersion] = useState(0);
39
+ const [cell] = useState((): StateCell => {
40
+ const commit = () => {
41
+ if (c.draft) c.current = c.draft.flush();
42
+ return c.current;
43
+ };
44
+ const c: StateCell = {
45
+ current: sanitizeValue(init()),
46
+ draft: null,
47
+ changed: [[]],
48
+ onOp: null,
49
+ onChange: null,
50
+ bump: () => setVersion((v) => v + 1),
51
+ ctl: {
52
+ apply: (ops) => {
53
+ const result = applyOps(commit(), ops);
54
+ if (result.events.length > 0) {
55
+ throw new Error(
56
+ "statewire: event ops do not apply to state — route them to emit",
57
+ );
58
+ }
59
+ c.current = result.state;
60
+ c.draft?.invalidate();
61
+ c.changed.push(...result.changedPaths);
62
+ if (c.onOp) for (const op of ops) c.onOp(op as ServerOp);
63
+ c.bump();
64
+ c.onChange?.();
65
+ },
66
+ snapshot: commit,
67
+ },
68
+ host: {
69
+ snapshot: commit,
70
+ setOnOp: (listener) => {
71
+ c.onOp = listener;
72
+ },
73
+ setOnChange: (listener) => {
74
+ c.onChange = listener;
75
+ },
76
+ takeChanged: () => {
77
+ const changed = c.changed;
78
+ c.changed = [];
79
+ return changed;
80
+ },
81
+ },
82
+ };
83
+ return c;
84
+ });
85
+
86
+ let value = cell.ctl.snapshot();
87
+ if (typeof value === "object" && value !== null) {
88
+ cell.draft ??= createStateDraft<object>({
89
+ snapshot: () => cell.current,
90
+ record: (op, changedPath) => {
91
+ cell.changed.push(changedPath);
92
+ cell.onOp?.(op);
93
+ cell.bump();
94
+ cell.onChange?.();
95
+ },
96
+ });
97
+ stateHosts.set(cell.draft.proxy, cell.host);
98
+ value = cell.draft.proxy;
99
+ }
100
+ return [value as TState, cell.ctl as StatewireHost.StateCtl<TState>] as const;
101
+ };
102
+
103
+ // ─── commands ───────────────────────────────────────────
104
+
105
+ type ExecutionSettled =
106
+ | { type: "resolved"; value: unknown }
107
+ | { type: "rejected"; error: unknown };
108
+
109
+ type ExecutionBind = {
110
+ onAck: () => void;
111
+ onSettle: (settled: ExecutionSettled) => void;
112
+ };
113
+
114
+ type ExecutionState = {
115
+ acked: boolean;
116
+ settled: ExecutionSettled | null;
117
+ bind: ExecutionBind | null;
118
+ };
119
+
120
+ const executionStates = new WeakMap<object, ExecutionState>();
121
+
122
+ /**
123
+ * `CommandExecution(executor)` runs the (possibly async) `executor({ ack })`
124
+ * immediately: its return value resolves the execution, a throw/rejection
125
+ * rejects it; `ack()` releases the client's ack watermark before the executor
126
+ * finishes. `CommandExecution.withResolvers()` returns `[execution,
127
+ * { ack, resolve, reject }]` instead — settle them from your own spawned work.
128
+ */
129
+ export const CommandExecution = Object.assign(
130
+ <T = void>(
131
+ executor: (resolvers: {
132
+ ack: () => void;
133
+ }) => T | void | PromiseLike<T | void>,
134
+ ): StatewireHost.Execution<T> => {
135
+ const state: ExecutionState = { acked: false, settled: null, bind: null };
136
+ const execution = Object.freeze(
137
+ {},
138
+ ) as unknown as StatewireHost.Execution<T>;
139
+ executionStates.set(execution, state);
140
+ const settle = (settled: ExecutionSettled) => {
141
+ if (state.settled) return;
142
+ state.settled = settled;
143
+ state.acked = true;
144
+ state.bind?.onSettle(settled);
145
+ };
146
+ const ack = () => {
147
+ if (state.acked) return;
148
+ state.acked = true;
149
+ state.bind?.onAck();
150
+ };
151
+ void (async () => {
152
+ try {
153
+ settle({ type: "resolved", value: await executor({ ack }) });
154
+ } catch (error) {
155
+ settle({ type: "rejected", error });
156
+ }
157
+ })();
158
+ return execution;
159
+ },
160
+ {
161
+ withResolvers: <T = void>(): [
162
+ StatewireHost.Execution<T>,
163
+ StatewireHost.ExecutionResolvers<T>,
164
+ ] => {
165
+ let resolve!: (value: T) => void;
166
+ let reject!: (error: unknown) => void;
167
+ const promise = new Promise<T>((res, rej) => {
168
+ resolve = res;
169
+ reject = rej;
170
+ });
171
+ let ack!: () => void;
172
+ const execution = CommandExecution<T>(({ ack: a }) => {
173
+ ack = a;
174
+ return promise;
175
+ });
176
+ return [execution, { ack: () => ack(), resolve, reject }];
177
+ },
178
+ },
179
+ );
180
+
181
+ export type CommandsInvocation = {
182
+ readonly method: string;
183
+ readonly params: readonly unknown[];
184
+ readonly ts: number;
185
+ readonly seq: number;
186
+ readonly clientId?: string;
187
+ };
188
+
189
+ export type CommandsOutcome =
190
+ | { type: "accepted"; payload?: unknown }
191
+ | { type: "rejected"; error: unknown };
192
+
193
+ export type CommandsListener = {
194
+ onStart: (inv: CommandsInvocation) => void;
195
+ onPosted?: (inv: CommandsInvocation) => void;
196
+ onAck: (inv: CommandsInvocation) => void;
197
+ /** Canonical state now covers the invocation's effect — drop any overlay. */
198
+ onCovered?: (inv: CommandsInvocation) => void;
199
+ onSettle: (inv: CommandsInvocation, outcome: CommandsOutcome) => void;
200
+ };
201
+
202
+ export type CommandsSubmission = {
203
+ method: string;
204
+ params: readonly unknown[];
205
+ clientId?: string;
206
+ onAck?: () => void;
207
+ onSettle?: (outcome: CommandsOutcome) => void;
208
+ };
209
+
210
+ export type CommandsHost = {
211
+ setListener: (listener: CommandsListener | null) => void;
212
+ submit?: (call: CommandsSubmission) => Promise<unknown>;
213
+ };
214
+
215
+ const commandsHosts = new WeakMap<object, CommandsHost>();
216
+
217
+ export const getCommandsHost = (commands: object): CommandsHost | undefined =>
218
+ commandsHosts.get(commands);
219
+
220
+ export const bindCommandsHost = (commands: object, host: CommandsHost) => {
221
+ commandsHosts.set(commands, host);
222
+ };
223
+
224
+ type QueuedCall = {
225
+ method: string;
226
+ params: readonly unknown[];
227
+ ts: number;
228
+ resolve: (value: unknown) => void;
229
+ reject: (error: unknown) => void;
230
+ origin?: CommandsSubmission;
231
+ };
232
+
233
+ type ActiveInvocation = CommandsInvocation & {
234
+ acked: boolean;
235
+ origin?: CommandsSubmission;
236
+ };
237
+
238
+ type CommandsCell = {
239
+ handlers: Record<string, (...args: never[]) => unknown> | null;
240
+ queue: QueuedCall[];
241
+ running: boolean;
242
+ listener: CommandsListener | null;
243
+ seq: number;
244
+ unmounted: boolean;
245
+ stallTimer: ReturnType<typeof setTimeout> | null;
246
+ };
247
+
248
+ const STALL_WARN_MS = 5000;
249
+
250
+ const toSendError = (error: unknown): StatewireSendError =>
251
+ error instanceof StatewireSendError
252
+ ? error
253
+ : new StatewireSendError({
254
+ fate: "rejected",
255
+ cause: "server",
256
+ message: error instanceof Error ? error.message : String(error),
257
+ ...(error instanceof StatewireReject &&
258
+ error.payload !== undefined && { payload: error.payload }),
259
+ });
260
+
261
+ const ackInvocation = (cell: CommandsCell, inv: ActiveInvocation) => {
262
+ if (inv.acked) return;
263
+ inv.acked = true;
264
+ cell.listener?.onAck(inv);
265
+ inv.origin?.onAck?.();
266
+ };
267
+
268
+ const settleInvocation = (
269
+ cell: CommandsCell,
270
+ inv: ActiveInvocation,
271
+ call: QueuedCall,
272
+ settled: ExecutionSettled,
273
+ ) => {
274
+ ackInvocation(cell, inv);
275
+ if (settled.type === "resolved") {
276
+ const outcome: CommandsOutcome =
277
+ settled.value === undefined
278
+ ? { type: "accepted" }
279
+ : { type: "accepted", payload: sanitizeValue(settled.value) };
280
+ cell.listener?.onSettle(inv, outcome);
281
+ inv.origin?.onSettle?.(outcome);
282
+ call.resolve(outcome.payload);
283
+ } else {
284
+ const error = toSendError(settled.error);
285
+ const outcome: CommandsOutcome = { type: "rejected", error };
286
+ cell.listener?.onSettle(inv, outcome);
287
+ inv.origin?.onSettle?.(outcome);
288
+ call.reject(error);
289
+ }
290
+ };
291
+
292
+ const pump = (cell: CommandsCell) => {
293
+ if (cell.running || !cell.handlers) {
294
+ watchStall(cell);
295
+ return;
296
+ }
297
+ const call = cell.queue.shift();
298
+ if (!call) return;
299
+ const handler = cell.handlers[call.method];
300
+ if (handler === undefined) {
301
+ const error = new StatewireSendError({
302
+ fate: "unknown-command",
303
+ cause: "server",
304
+ });
305
+ call.origin?.onSettle?.({ type: "rejected", error });
306
+ call.reject(error);
307
+ pump(cell);
308
+ return;
309
+ }
310
+ const inv: ActiveInvocation = {
311
+ method: call.method,
312
+ params: call.params,
313
+ ts: call.ts,
314
+ seq: ++cell.seq,
315
+ acked: false,
316
+ ...(call.origin !== undefined && { origin: call.origin }),
317
+ ...(call.origin?.clientId !== undefined && {
318
+ clientId: call.origin.clientId,
319
+ }),
320
+ };
321
+ cell.running = true;
322
+ cell.listener?.onStart(inv);
323
+ cell.listener?.onPosted?.(inv);
324
+ cell.listener?.onCovered?.(inv);
325
+ void (async () => {
326
+ try {
327
+ const value = await (handler as (...args: readonly unknown[]) => unknown)(
328
+ ...call.params,
329
+ );
330
+ const execution =
331
+ typeof value === "object" && value !== null
332
+ ? executionStates.get(value)
333
+ : undefined;
334
+ if (execution) {
335
+ if (execution.acked) ackInvocation(cell, inv);
336
+ if (execution.settled) {
337
+ settleInvocation(cell, inv, call, execution.settled);
338
+ } else {
339
+ execution.bind = {
340
+ onAck: () => ackInvocation(cell, inv),
341
+ onSettle: (settled) => settleInvocation(cell, inv, call, settled),
342
+ };
343
+ }
344
+ } else {
345
+ settleInvocation(cell, inv, call, { type: "resolved", value });
346
+ }
347
+ } catch (error) {
348
+ settleInvocation(cell, inv, call, { type: "rejected", error });
349
+ } finally {
350
+ cell.running = false;
351
+ pump(cell);
352
+ }
353
+ })();
354
+ };
355
+
356
+ const watchStall = (cell: CommandsCell) => {
357
+ if (!isDev() || cell.stallTimer !== null || cell.queue.length === 0) return;
358
+ if (cell.handlers) return;
359
+ cell.stallTimer = setTimeout(() => {
360
+ cell.stallTimer = null;
361
+ if (cell.handlers === null && cell.queue.length > 0 && !cell.unmounted) {
362
+ console.warn(
363
+ `statewire: ${cell.queue.length} buffered command(s) waiting over ${STALL_WARN_MS}ms — did the host forget to arm useStatewireCommands?`,
364
+ );
365
+ }
366
+ }, STALL_WARN_MS);
367
+ };
368
+
369
+ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
370
+ commands: THandlers | undefined | null | false,
371
+ ): Statewire.CommandsProxy<StatewireHost.HandledCommands<THandlers>> => {
372
+ const [built] = useState(() => {
373
+ const cell: CommandsCell = {
374
+ handlers: null,
375
+ queue: [],
376
+ running: false,
377
+ listener: null,
378
+ seq: 0,
379
+ unmounted: false,
380
+ stallTimer: null,
381
+ };
382
+ const proxy = createMethodProxy<
383
+ Statewire.CommandsProxy<StatewireHost.HandledCommands<THandlers>>
384
+ >(
385
+ (method, params) =>
386
+ new Promise((resolve, reject) => {
387
+ if (cell.unmounted) {
388
+ reject(new Error("statewire: commands called after unmount"));
389
+ return;
390
+ }
391
+ cell.queue.push({
392
+ method,
393
+ params,
394
+ ts: Date.now(),
395
+ resolve,
396
+ reject,
397
+ });
398
+ pump(cell);
399
+ }),
400
+ );
401
+ commandsHosts.set(proxy, {
402
+ setListener: (listener) => {
403
+ cell.listener = listener;
404
+ },
405
+ submit: (origin) =>
406
+ new Promise((resolve, reject) => {
407
+ if (cell.unmounted) {
408
+ reject(new Error("statewire: commands called after unmount"));
409
+ return;
410
+ }
411
+ cell.queue.push({
412
+ method: origin.method,
413
+ params: origin.params,
414
+ ts: Date.now(),
415
+ resolve,
416
+ reject,
417
+ origin,
418
+ });
419
+ pump(cell);
420
+ }),
421
+ });
422
+ return { cell, proxy };
423
+ });
424
+
425
+ useEffect(() => {
426
+ const { cell } = built;
427
+ cell.unmounted = false;
428
+ return () => {
429
+ cell.unmounted = true;
430
+ cell.handlers = null;
431
+ if (cell.stallTimer !== null) {
432
+ clearTimeout(cell.stallTimer);
433
+ cell.stallTimer = null;
434
+ }
435
+ queueMicrotask(() => {
436
+ if (!cell.unmounted) return;
437
+ const queued = cell.queue;
438
+ cell.queue = [];
439
+ for (const call of queued) {
440
+ call.reject(new Error("statewire: unmounted"));
441
+ }
442
+ });
443
+ };
444
+ }, [built]);
445
+
446
+ useEffect(() => {
447
+ built.cell.handlers = commands || null;
448
+ pump(built.cell);
449
+ });
450
+
451
+ return built.proxy;
452
+ };
453
+
454
+ // ─── emit ───────────────────────────────────────────────
455
+
456
+ export type EmitHost = {
457
+ setSink: (sink: ((event: unknown) => void) | null) => void;
458
+ };
459
+
460
+ const emitHosts = new WeakMap<object, EmitHost>();
461
+
462
+ export const getEmitHost = (emit: object | undefined): EmitHost | undefined =>
463
+ emit && emitHosts.get(emit);
464
+
465
+ export const useStatewireEmit = <TEvent>(): ((event: TEvent) => void) => {
466
+ const [built] = useState(() => {
467
+ const cell = { sink: null as ((event: unknown) => void) | null };
468
+ const emit = (event: TEvent) => {
469
+ if (!cell.sink) {
470
+ throw new Error("statewire: emit before a host bound the event sink");
471
+ }
472
+ cell.sink(sanitizeValue(event));
473
+ };
474
+ emitHosts.set(emit, {
475
+ setSink: (sink) => {
476
+ cell.sink = sink;
477
+ },
478
+ });
479
+ return emit;
480
+ });
481
+ return built;
482
+ };