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.
Files changed (148) hide show
  1. package/README.md +14 -13
  2. package/dist/client/changes.d.ts +2 -2
  3. package/dist/client/changes.d.ts.map +1 -1
  4. package/dist/client/changes.js +3 -10
  5. package/dist/client/changes.js.map +1 -1
  6. package/dist/client/connection.d.ts +18 -39
  7. package/dist/client/connection.d.ts.map +1 -1
  8. package/dist/client/connection.js +61 -54
  9. package/dist/client/connection.js.map +1 -1
  10. package/dist/client/core.d.ts +7 -11
  11. package/dist/client/core.d.ts.map +1 -1
  12. package/dist/client/core.js +58 -55
  13. package/dist/client/core.js.map +1 -1
  14. package/dist/client/session.d.ts +12 -26
  15. package/dist/client/session.d.ts.map +1 -1
  16. package/dist/client/session.js +70 -43
  17. package/dist/client/session.js.map +1 -1
  18. package/dist/client/sessionStore.d.ts +1 -1
  19. package/dist/client/sessionStore.js.map +1 -1
  20. package/dist/client/timing.d.ts +2 -1
  21. package/dist/client/timing.d.ts.map +1 -1
  22. package/dist/client/timing.js +5 -1
  23. package/dist/client/timing.js.map +1 -1
  24. package/dist/host/adapter.d.ts +8 -0
  25. package/dist/host/adapter.d.ts.map +1 -0
  26. package/dist/host/adapter.js +23 -0
  27. package/dist/host/adapter.js.map +1 -0
  28. package/dist/host/draft.d.ts +21 -7
  29. package/dist/host/draft.d.ts.map +1 -1
  30. package/dist/host/draft.js +190 -103
  31. package/dist/host/draft.js.map +1 -1
  32. package/dist/host/host.d.ts +19 -35
  33. package/dist/host/host.d.ts.map +1 -1
  34. package/dist/host/host.js +86 -54
  35. package/dist/host/host.js.map +1 -1
  36. package/dist/host/socket.d.ts +14 -0
  37. package/dist/host/socket.d.ts.map +1 -0
  38. package/dist/host/socket.js +511 -0
  39. package/dist/host/socket.js.map +1 -0
  40. package/dist/host-internal.d.ts +27 -0
  41. package/dist/host-internal.d.ts.map +1 -0
  42. package/dist/host-internal.js +3 -0
  43. package/dist/host.d.ts +54 -0
  44. package/dist/host.d.ts.map +1 -0
  45. package/dist/host.js +4 -0
  46. package/dist/index.d.ts +5 -28
  47. package/dist/index.js +2 -3
  48. package/dist/statewire.d.ts +97 -10
  49. package/dist/statewire.d.ts.map +1 -1
  50. package/dist/statewire.js +32 -36
  51. package/dist/statewire.js.map +1 -1
  52. package/dist/transports/fake-sse.d.ts +12 -0
  53. package/dist/transports/fake-sse.d.ts.map +1 -0
  54. package/dist/transports/fake-sse.js +39 -0
  55. package/dist/transports/fake-sse.js.map +1 -0
  56. package/dist/transports/fixtures.d.ts +33 -0
  57. package/dist/transports/fixtures.d.ts.map +1 -0
  58. package/dist/transports/fixtures.js +50 -0
  59. package/dist/transports/fixtures.js.map +1 -0
  60. package/dist/transports/remote.d.ts +6 -13
  61. package/dist/transports/remote.d.ts.map +1 -1
  62. package/dist/transports/remote.js +7 -25
  63. package/dist/transports/remote.js.map +1 -1
  64. package/dist/transports/sse.d.ts +32 -20
  65. package/dist/transports/sse.d.ts.map +1 -1
  66. package/dist/transports/sse.js +57 -46
  67. package/dist/transports/sse.js.map +1 -1
  68. package/dist/transports/testing.d.ts +8 -6
  69. package/dist/transports/testing.d.ts.map +1 -1
  70. package/dist/transports/testing.js +19 -46
  71. package/dist/transports/testing.js.map +1 -1
  72. package/dist/transports/ws.d.ts +4 -13
  73. package/dist/transports/ws.d.ts.map +1 -1
  74. package/dist/transports/ws.js +59 -63
  75. package/dist/transports/ws.js.map +1 -1
  76. package/dist/types.d.ts +55 -100
  77. package/dist/types.d.ts.map +1 -1
  78. package/dist/types.js.map +1 -1
  79. package/dist/util/draftTraps.d.ts +6 -0
  80. package/dist/util/draftTraps.d.ts.map +1 -0
  81. package/dist/util/draftTraps.js +25 -0
  82. package/dist/util/draftTraps.js.map +1 -0
  83. package/dist/util/macrotask.d.ts +5 -0
  84. package/dist/util/macrotask.d.ts.map +1 -0
  85. package/dist/util/macrotask.js +25 -0
  86. package/dist/util/macrotask.js.map +1 -0
  87. package/dist/util/methodProxy.d.ts +5 -0
  88. package/dist/util/methodProxy.d.ts.map +1 -0
  89. package/dist/util/methodProxy.js +17 -0
  90. package/dist/util/methodProxy.js.map +1 -0
  91. package/dist/util/randomId.d.ts +5 -0
  92. package/dist/util/randomId.d.ts.map +1 -0
  93. package/dist/util/randomId.js +6 -0
  94. package/dist/util/randomId.js.map +1 -0
  95. package/dist/{client/optimistic.d.ts → util/recordMutations.d.ts} +2 -2
  96. package/dist/util/recordMutations.d.ts.map +1 -0
  97. package/dist/{client/optimistic.js → util/recordMutations.js} +5 -24
  98. package/dist/util/recordMutations.js.map +1 -0
  99. package/dist/util/statePath.d.ts +14 -0
  100. package/dist/util/statePath.d.ts.map +1 -0
  101. package/dist/util/statePath.js +31 -0
  102. package/dist/util/statePath.js.map +1 -0
  103. package/dist/wire/meta.d.ts +6 -1
  104. package/dist/wire/meta.d.ts.map +1 -1
  105. package/dist/wire/meta.js +30 -28
  106. package/dist/wire/meta.js.map +1 -1
  107. package/dist/wire/ops.d.ts +5 -26
  108. package/dist/wire/ops.d.ts.map +1 -1
  109. package/dist/wire/ops.js +67 -80
  110. package/dist/wire/ops.js.map +1 -1
  111. package/package.json +15 -11
  112. package/src/client/changes.ts +5 -15
  113. package/src/client/connection.ts +87 -113
  114. package/src/client/core.ts +57 -91
  115. package/src/client/session.ts +129 -90
  116. package/src/client/sessionStore.ts +1 -1
  117. package/src/client/timing.ts +6 -0
  118. package/src/host/adapter.ts +33 -0
  119. package/src/host/draft.ts +293 -125
  120. package/src/host/host.ts +122 -110
  121. package/src/host/socket.ts +618 -0
  122. package/src/host-internal.ts +28 -0
  123. package/src/host.ts +72 -0
  124. package/src/index.ts +2 -62
  125. package/src/statewire.ts +168 -61
  126. package/src/transports/fake-sse.ts +51 -0
  127. package/src/transports/fixtures.ts +60 -0
  128. package/src/transports/remote.ts +14 -51
  129. package/src/transports/sse.ts +87 -89
  130. package/src/transports/testing.ts +33 -63
  131. package/src/transports/ws.ts +78 -100
  132. package/src/types.ts +52 -131
  133. package/src/util/draftTraps.ts +32 -0
  134. package/src/util/macrotask.ts +25 -0
  135. package/src/util/methodProxy.ts +16 -0
  136. package/src/util/randomId.ts +4 -0
  137. package/src/{client/optimistic.ts → util/recordMutations.ts} +0 -0
  138. package/src/util/statePath.ts +61 -0
  139. package/src/wire/meta.ts +42 -47
  140. package/src/wire/ops.ts +107 -106
  141. package/LICENSE +0 -21
  142. package/dist/client/optimistic.d.ts.map +0 -1
  143. package/dist/client/optimistic.js.map +0 -1
  144. package/dist/index.d.ts.map +0 -1
  145. package/dist/server.d.ts +0 -15
  146. package/dist/server.d.ts.map +0 -1
  147. package/dist/server.js +0 -3
  148. package/src/server.ts +0 -32
package/src/host/draft.ts CHANGED
@@ -1,34 +1,28 @@
1
- import type { StatewireOp, StatewirePath } from "../wire/ops";
1
+ import type { Statewire } from "../types";
2
+ import type { StatewirePath } from "../wire/ops";
3
+ import { sanitizeValue } from "../wire/ops";
4
+ import { isUnsafeKey } from "../util/statePath";
5
+ import { sharedDraftTraps } from "../util/draftTraps";
2
6
 
3
- export type ServerOp = Exclude<StatewireOp, { op: "event" }>;
7
+ export type ServerOp = Exclude<Statewire.Op, { op: "event" }>;
4
8
 
5
9
  export type DraftHost = {
6
- root: () => unknown;
7
- record: (op: ServerOp) => void;
10
+ snapshot: () => unknown;
11
+ record: (op: ServerOp, changedPath: StatewirePath) => void;
8
12
  };
9
13
 
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;
14
+ export type StateDraft<T> = {
15
+ proxy: T;
16
+ /** Seal pending writes and return the committed state. */
17
+ flush: () => unknown;
18
+ /** Committed state was replaced externally; cached nodes re-resolve. */
19
+ invalidate: () => void;
24
20
  };
25
21
 
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;
22
+ const NODE = Symbol("statewire.draftNode");
23
+
24
+ type NodeHandle = {
25
+ append: (key: string | number, suffix: string) => void;
32
26
  };
33
27
 
34
28
  const isIndex = (key: string) => /^(0|[1-9]\d*)$/.test(key);
@@ -36,120 +30,294 @@ const isIndex = (key: string) => /^(0|[1-9]\d*)$/.test(key);
36
30
  const invalid = (path: StatewirePath, message: string) =>
37
31
  new Error(`statewire: ${message} at path: ${path.join(".")}`);
38
32
 
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;
33
+ type Container = Record<string | number, unknown>;
34
+
35
+ type Node = {
36
+ parent: Node | null;
37
+ key: string | number;
38
+ path: StatewirePath;
39
+ gen: number;
40
+ base: unknown;
41
+ copy: Container | null;
42
+ children: Map<string, Node>;
43
+ isArray: boolean;
44
+ proxy: object;
45
+ handle: NodeHandle;
46
+ };
47
+
48
+ export const createStateDraft = <T>(host: DraftHost): StateDraft<T> => {
49
+ let gen = 0;
50
+ const dirty: Node[] = [];
51
+
52
+ const latest = (node: Node): unknown => node.copy ?? node.base;
53
+
54
+ const resolve = (node: Node): Node => {
55
+ if (node.gen === gen) return node;
56
+ node.gen = gen;
57
+ if (node.parent === null) {
58
+ if (node.copy === null) node.base = host.snapshot();
59
+ return node;
60
+ }
61
+ const value = (latest(resolve(node.parent)) as Container)[node.key];
62
+ if (value !== latest(node)) {
63
+ node.base = value;
64
+ node.copy = null;
65
+ }
66
+ return node;
67
+ };
68
+
69
+ const prepareForWrite = (node: Node): Container => {
70
+ resolve(node);
71
+ if (node.copy !== null) return node.copy;
72
+ const base = node.base;
73
+ node.copy = Array.isArray(base)
74
+ ? (base.slice() as unknown as Container)
75
+ : { ...(base as Container) };
76
+ dirty.push(node);
77
+ if (node.parent !== null) {
78
+ prepareForWrite(node.parent)[node.key] = node.copy;
63
79
  }
64
- host.record({ op: "replace", path: [...path, key], value });
80
+ return node.copy;
65
81
  };
66
82
 
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];
83
+ const changedPathFor = (node: Node, key: string | number): StatewirePath =>
84
+ Array.isArray(latest(node)) ? node.path : [...node.path, key];
85
+
86
+ const applyAppend = (node: Node, rawKey: string | number, suffix: string) => {
87
+ const container = latest(resolve(node)) as Container;
88
+ const key =
89
+ Array.isArray(container) && typeof rawKey === "string" && isIndex(rawKey)
90
+ ? Number(rawKey)
91
+ : rawKey;
92
+ const existing = Object.hasOwn(container as object, key)
93
+ ? container[key]
94
+ : undefined;
95
+ if (typeof existing !== "string") {
96
+ throw invalid([...node.path, key], "cannot append to a non-string");
97
+ }
98
+ if (suffix === "") return;
99
+ prepareForWrite(node)[key] = existing + suffix;
100
+ host.record(
101
+ { op: "add", path: [...node.path, key, existing.length], value: suffix },
102
+ changedPathFor(node, key),
103
+ );
104
+ };
105
+
106
+ const createNode = (parent: Node | null, key: string | number): Node => {
107
+ const seed =
108
+ parent === null ? host.snapshot() : (latest(parent) as Container)[key];
109
+ const node: Node = {
110
+ parent,
111
+ key,
112
+ path: parent === null ? [] : [...parent.path, key],
113
+ gen,
114
+ base: seed,
115
+ copy: null,
116
+ children: new Map(),
117
+ isArray: Array.isArray(seed),
118
+ proxy: null!,
119
+ handle: null!,
120
+ };
121
+ node.handle = {
122
+ append: (childKey, suffix) => applyAppend(node, childKey, suffix),
123
+ };
124
+
125
+ const current = () => latest(resolve(node));
126
+
127
+ const childProxy = (
128
+ prop: string,
129
+ childKey: string | number,
130
+ value: object,
131
+ ) => {
132
+ let child = node.children.get(prop);
133
+ if (child === undefined || child.isArray !== Array.isArray(value)) {
134
+ child = createNode(node, childKey);
135
+ node.children.set(prop, child);
72
136
  }
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]);
137
+ return child.proxy;
138
+ };
139
+
140
+ const assign = (
141
+ container: Container,
142
+ key: string | number,
143
+ value: unknown,
144
+ ) => {
145
+ const old = Object.hasOwn(container as object, key)
146
+ ? container[key]
147
+ : undefined;
148
+ if (old === value) return;
149
+ const isObjectContainer = !Array.isArray(container);
150
+ if (
151
+ isObjectContainer &&
152
+ isUnsafeKey(key, node.path[node.path.length - 1])
153
+ ) {
154
+ throw invalid([...node.path, key], `cannot set unsafe key "${key}"`);
77
155
  }
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;
156
+ if (value === undefined) {
157
+ if (!isObjectContainer) {
158
+ throw invalid([...node.path, key], "cannot serialize undefined");
94
159
  }
95
- if (!isIndex(prop)) {
96
- throw invalid(path, `cannot set key "${prop}" on a list`);
160
+ delete prepareForWrite(node)[key];
161
+ host.record({ op: "remove", path: [...node.path, key] }, [
162
+ ...node.path,
163
+ key,
164
+ ]);
165
+ gen++;
166
+ return;
167
+ }
168
+ prepareForWrite(node)[key] = isObjectContainer
169
+ ? sanitizeValue(value, key as string)
170
+ : sanitizeValue(value);
171
+ host.record(
172
+ { op: "replace", path: [...node.path, key], value },
173
+ changedPathFor(node, key),
174
+ );
175
+ if (
176
+ (typeof old === "object" && old !== null) ||
177
+ (typeof value === "object" && value !== null)
178
+ ) {
179
+ gen++;
180
+ }
181
+ };
182
+
183
+ node.proxy = new Proxy(seed as object, {
184
+ get: (_target, prop) => {
185
+ if (prop === NODE) return node.handle;
186
+ const container = current();
187
+ if (typeof prop === "symbol") {
188
+ return (container as Record<symbol, unknown>)[prop];
97
189
  }
98
- const index = Number(prop);
99
- if (index > current.length) {
100
- throw invalid(path, `list index ${index} out of range`);
190
+ const c = container as Container;
191
+ const key = Array.isArray(c) && isIndex(prop) ? Number(prop) : prop;
192
+ const value = c[key];
193
+ if (typeof value === "object" && value !== null) {
194
+ return childProxy(prop, key, value);
101
195
  }
102
- if (index === current.length) {
103
- host.record({ op: "add", path: [...path, index], value });
196
+ return value;
197
+ },
198
+ set: (_target, prop, value) => {
199
+ if (typeof prop === "symbol") return false;
200
+ const container = current();
201
+ if (Array.isArray(container)) {
202
+ if (prop === "length") {
203
+ const next = Number(value);
204
+ if (
205
+ !Number.isInteger(next) ||
206
+ next < 0 ||
207
+ next > container.length
208
+ ) {
209
+ throw invalid(
210
+ node.path,
211
+ `cannot set list length to ${String(value)}`,
212
+ );
213
+ }
214
+ if (next === container.length) return true;
215
+ const copy = prepareForWrite(node) as unknown as unknown[];
216
+ for (let i = container.length - 1; i >= next; i--) {
217
+ copy.splice(i, 1);
218
+ host.record({ op: "remove", path: [...node.path, i] }, node.path);
219
+ }
220
+ gen++;
221
+ return true;
222
+ }
223
+ if (!isIndex(prop)) {
224
+ throw invalid(node.path, `cannot set key "${prop}" on a list`);
225
+ }
226
+ const index = Number(prop);
227
+ if (index > container.length) {
228
+ throw invalid(node.path, `list index ${index} out of range`);
229
+ }
230
+ if (index === container.length) {
231
+ if (value === undefined) {
232
+ throw invalid(
233
+ [...node.path, index],
234
+ "cannot serialize undefined",
235
+ );
236
+ }
237
+ prepareForWrite(node)[index] = sanitizeValue(value);
238
+ host.record(
239
+ { op: "add", path: [...node.path, index], value },
240
+ node.path,
241
+ );
242
+ gen++;
243
+ return true;
244
+ }
245
+ assign(container as unknown as Container, index, value);
104
246
  return true;
105
247
  }
106
- assign(
107
- current as unknown as Record<string | number, unknown>,
108
- index,
109
- value,
110
- );
248
+ assign(container as Container, prop, value);
111
249
  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)] });
250
+ },
251
+ deleteProperty: (_target, prop) => {
252
+ if (typeof prop === "symbol") return false;
253
+ const container = current();
254
+ if (Array.isArray(container)) {
255
+ if (!isIndex(prop) || Number(prop) >= container.length) return true;
256
+ const index = Number(prop);
257
+ (prepareForWrite(node) as unknown as unknown[]).splice(index, 1);
258
+ host.record({ op: "remove", path: [...node.path, index] }, node.path);
259
+ gen++;
260
+ return true;
261
+ }
262
+ if (!Object.hasOwn(container as object, prop)) return true;
263
+ if (isUnsafeKey(prop, node.path[node.path.length - 1])) {
264
+ throw invalid(
265
+ [...node.path, prop],
266
+ `cannot remove unsafe key "${prop}"`,
267
+ );
268
+ }
269
+ delete prepareForWrite(node)[prop];
270
+ host.record({ op: "remove", path: [...node.path, prop] }, [
271
+ ...node.path,
272
+ prop,
273
+ ]);
274
+ gen++;
122
275
  return true;
276
+ },
277
+ has: (_target, prop) => Reflect.has(current() as object, prop),
278
+ ...sharedDraftTraps(() => current() as object, "on a state draft"),
279
+ });
280
+ return node;
281
+ };
282
+
283
+ const root = createNode(null, "");
284
+
285
+ return {
286
+ proxy: root.proxy as T,
287
+ flush: () => {
288
+ if (dirty.length === 0) return host.snapshot();
289
+ for (const node of dirty) {
290
+ if (node.copy !== null) {
291
+ node.base = node.copy;
292
+ node.copy = null;
293
+ }
123
294
  }
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
- );
295
+ dirty.length = 0;
296
+ return root.base;
132
297
  },
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
- );
298
+ invalidate: () => {
299
+ gen++;
142
300
  },
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;
301
+ };
302
+ };
303
+
304
+ /**
305
+ * Append `suffix` to the string at `parent[key]` on a statewire state draft,
306
+ * recording a single end-offset text-insert `add` op instead of a full
307
+ * `replace` — use it on token-streaming hot paths. Throws when the current
308
+ * value is not a string.
309
+ */
310
+ export const append = <TKey extends string | number>(
311
+ parent: { readonly [P in TKey]: string },
312
+ key: TKey,
313
+ suffix: string,
314
+ ): void => {
315
+ const handle = (parent as { [NODE]?: NodeHandle })[NODE];
316
+ if (handle === undefined) {
317
+ throw new Error("statewire: append expects a state draft object");
318
+ }
319
+ if (typeof suffix !== "string") {
320
+ throw new Error("statewire: append suffix must be a string");
321
+ }
322
+ handle.append(key, suffix);
155
323
  };