shared-reducer 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +366 -0
- package/backend/index.d.ts +163 -0
- package/backend/index.js +1 -0
- package/backend/index.mjs +1 -0
- package/frontend/index.d.ts +56 -0
- package/frontend/index.js +1 -0
- package/frontend/index.mjs +1 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019-2024 David Evans
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# Shared Reducer
|
|
2
|
+
|
|
3
|
+
Shared state management via websockets.
|
|
4
|
+
|
|
5
|
+
Designed to work with
|
|
6
|
+
[json-immutability-helper](https://github.com/davidje13/json-immutability-helper).
|
|
7
|
+
|
|
8
|
+
## Install dependency
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install --save shared-reducer json-immutability-helper
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
(if you want to use an alternative reducer, see the instructions below).
|
|
15
|
+
|
|
16
|
+
## Usage (Backend)
|
|
17
|
+
|
|
18
|
+
This project is compatible with
|
|
19
|
+
[websocket-express](https://github.com/davidje13/websocket-express),
|
|
20
|
+
but can also be used in isolation.
|
|
21
|
+
|
|
22
|
+
### With websocket-express
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import {
|
|
26
|
+
Broadcaster,
|
|
27
|
+
websocketHandler,
|
|
28
|
+
InMemoryModel,
|
|
29
|
+
ReadWrite,
|
|
30
|
+
} from 'shared-reducer/backend';
|
|
31
|
+
import context from 'json-immutability-helper';
|
|
32
|
+
import { WebSocketExpress } from 'websocket-express';
|
|
33
|
+
|
|
34
|
+
const model = new InMemoryModel();
|
|
35
|
+
const broadcaster = Broadcaster.for(model)
|
|
36
|
+
.withReducer(context)
|
|
37
|
+
.build();
|
|
38
|
+
model.set('a', { foo: 'v1' });
|
|
39
|
+
|
|
40
|
+
const app = new WebSocketExpress();
|
|
41
|
+
const server = app.listen(0, 'localhost');
|
|
42
|
+
|
|
43
|
+
const handler = websocketHandler(broadcaster);
|
|
44
|
+
app.ws('/:id', handler((req) => req.params.id, () => ReadWrite));
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For real use-cases, you will probably want to add authentication middleware
|
|
48
|
+
to the expressjs chain, and you may want to give some users read-only and
|
|
49
|
+
others read-write access, which can be achieved in the second lambda.
|
|
50
|
+
|
|
51
|
+
### Alone
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
import { Broadcaster, InMemoryModel } from 'shared-reducer/backend';
|
|
55
|
+
import context from 'json-immutability-helper';
|
|
56
|
+
|
|
57
|
+
const model = new InMemoryModel();
|
|
58
|
+
const broadcaster = Broadcaster.for(model)
|
|
59
|
+
.withReducer(context)
|
|
60
|
+
.build();
|
|
61
|
+
model.set('a', { foo: 'v1' });
|
|
62
|
+
|
|
63
|
+
// ...
|
|
64
|
+
|
|
65
|
+
const subscription = await broadcaster.subscribe(
|
|
66
|
+
'a',
|
|
67
|
+
(change, meta) => { /*...*/ },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const begin = subscription.getInitialData();
|
|
71
|
+
await subscription.send(['=', { foo: 'v2' }]);
|
|
72
|
+
// callback provided earlier is invoked
|
|
73
|
+
|
|
74
|
+
await subscription.close();
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Persisting data
|
|
78
|
+
|
|
79
|
+
A convenience wrapper is provided for use with
|
|
80
|
+
[collection-storage](https://github.com/davidje13/collection-storage),
|
|
81
|
+
or you can write your own implementation of the `Model` interface to
|
|
82
|
+
link any backend.
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
import {
|
|
86
|
+
Broadcaster,
|
|
87
|
+
CollectionStorageModel,
|
|
88
|
+
} from 'shared-reducer/backend';
|
|
89
|
+
import context from 'json-immutability-helper';
|
|
90
|
+
import CollectionStorage from 'collection-storage';
|
|
91
|
+
|
|
92
|
+
const db = await CollectionStorage.connect('memory://something');
|
|
93
|
+
const model = new CollectionStorageModel(
|
|
94
|
+
db.getCollection('foo'),
|
|
95
|
+
'id',
|
|
96
|
+
// a function which takes in an object and returns it if valid,
|
|
97
|
+
// or throws if invalid (protects stored data from malicious changes)
|
|
98
|
+
MY_VALIDATOR,
|
|
99
|
+
);
|
|
100
|
+
const broadcaster = Broadcaster.for(model)
|
|
101
|
+
.withReducer(context)
|
|
102
|
+
.build();
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Note that the provided validator MUST verify structural integrity (e.g.
|
|
106
|
+
ensuring no unexpected fields are added or types are changed).
|
|
107
|
+
|
|
108
|
+
## Usage (Frontend)
|
|
109
|
+
|
|
110
|
+
```javascript
|
|
111
|
+
import { SharedReducer, actionsHandledCallback, actionsSyncedCallback } from 'shared-reducer/frontend';
|
|
112
|
+
import context from 'json-immutability-helper';
|
|
113
|
+
|
|
114
|
+
const reducer = SharedReducer
|
|
115
|
+
.for('ws://destination', (state) => {
|
|
116
|
+
console.log('latest state is', state);
|
|
117
|
+
})
|
|
118
|
+
.withReducer(context)
|
|
119
|
+
.withToken('my-token')
|
|
120
|
+
.withErrorHandler((error) => { console.log('connection lost', error); })
|
|
121
|
+
.withWarningHandler((warning) => { console.log('latest change failed', warning); })
|
|
122
|
+
.build();
|
|
123
|
+
|
|
124
|
+
const dispatch = reducer.dispatch;
|
|
125
|
+
|
|
126
|
+
dispatch([
|
|
127
|
+
{ a: ['=', 8] },
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
dispatch([
|
|
131
|
+
(state) => {
|
|
132
|
+
return {
|
|
133
|
+
a: ['=', Math.pow(2, state.a)],
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
dispatch([
|
|
139
|
+
actionsHandledCallback((state) => {
|
|
140
|
+
console.log('state after handling is', state);
|
|
141
|
+
}),
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
dispatch([
|
|
145
|
+
actionsSyncedCallback((state) => {
|
|
146
|
+
console.log('state after syncing is', state);
|
|
147
|
+
}),
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
dispatch([
|
|
151
|
+
{ a: ['add', 1] },
|
|
152
|
+
{ a: ['add', 1] },
|
|
153
|
+
]);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Specs
|
|
157
|
+
|
|
158
|
+
The specs need to match whichever reducer you are using. In the examples
|
|
159
|
+
above, that is
|
|
160
|
+
[json-immutability-helper](https://github.com/davidje13/json-immutability-helper).
|
|
161
|
+
|
|
162
|
+
## WebSocket protocol
|
|
163
|
+
|
|
164
|
+
The websocket protocol is minimal:
|
|
165
|
+
|
|
166
|
+
### Client-to-server
|
|
167
|
+
|
|
168
|
+
`<token>`:
|
|
169
|
+
The authentication token is sent as the first message when the connection is
|
|
170
|
+
established. This is plaintext. The server should respond by either terminating
|
|
171
|
+
the connection (if the token is deemed invalid), or with an `init` event which
|
|
172
|
+
defines the latest state in its entirety. If no token is specified using
|
|
173
|
+
`withToken`, no message will be sent (when not using authentication, it is
|
|
174
|
+
assumed the server will send the `init` event unprompted).
|
|
175
|
+
|
|
176
|
+
`P` (ping):
|
|
177
|
+
Can be sent periodically to keep the connection alive. The server sends a
|
|
178
|
+
"Pong" message in response immediately.
|
|
179
|
+
|
|
180
|
+
`{"change": <spec>, "id": <id>}`:
|
|
181
|
+
Defines a delta. This may contain the aggregate result of many operations
|
|
182
|
+
performed on the client. The ID is an opaque identifier which is reflected
|
|
183
|
+
back to the same client in the confirmation message. Other clients will not
|
|
184
|
+
receive the ID.
|
|
185
|
+
|
|
186
|
+
### Server-to-client
|
|
187
|
+
|
|
188
|
+
`p` (pong):
|
|
189
|
+
Reponse to a ping. May also be sent unsolicited.
|
|
190
|
+
|
|
191
|
+
`{"init": <state>}`:
|
|
192
|
+
The first message sent by the server, in response to a successful
|
|
193
|
+
connection.
|
|
194
|
+
|
|
195
|
+
`{"change": <spec>}`:
|
|
196
|
+
Sent whenever another client has changed the server state.
|
|
197
|
+
|
|
198
|
+
`{"change": <spec>, "id": <id>}`:
|
|
199
|
+
Sent whenever the current client has changed the server state. Note that
|
|
200
|
+
the spec and ID will match the client-sent values.
|
|
201
|
+
|
|
202
|
+
The IDs sent by different clients can coincide, so the ID is only reflected
|
|
203
|
+
to the client which sent the spec.
|
|
204
|
+
|
|
205
|
+
`{"error": <message>, "id": <id>}`:
|
|
206
|
+
Sent if the server rejects a client-initiated change.
|
|
207
|
+
|
|
208
|
+
If this is returned, the server state will not have changed (i.e. the
|
|
209
|
+
entire spec failed).
|
|
210
|
+
|
|
211
|
+
### Specs
|
|
212
|
+
|
|
213
|
+
The specs need to match whichever reducer you are using. In the examples
|
|
214
|
+
above, that is
|
|
215
|
+
[json-immutability-helper](https://github.com/davidje13/json-immutability-helper).
|
|
216
|
+
|
|
217
|
+
## Alternative reducer
|
|
218
|
+
|
|
219
|
+
To enable different features of `json-immutability-helper`, you can
|
|
220
|
+
customise it before passing it to `withReducer`. For example, to
|
|
221
|
+
enable list commands such as `updateWhere` and mathematical commands
|
|
222
|
+
such as Reverse Polish Notation (`rpn`):
|
|
223
|
+
|
|
224
|
+
### Backend
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
import { Broadcaster, InMemoryModel } from 'shared-reducer/backend';
|
|
228
|
+
import listCommands from 'json-immutability-helper/commands/list';
|
|
229
|
+
import mathCommands from 'json-immutability-helper/commands/math';
|
|
230
|
+
import context from 'json-immutability-helper';
|
|
231
|
+
|
|
232
|
+
const broadcaster = Broadcaster.for(new InMemoryModel())
|
|
233
|
+
.withReducer(context.with(listCommands, mathCommands))
|
|
234
|
+
.build();
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
If you want to use an entirely different reducer, create a wrapper
|
|
238
|
+
and pass it to `withReducer`:
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
import { Broadcaster, InMemoryModel } from 'shared-reducer/backend';
|
|
242
|
+
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Frontend
|
|
246
|
+
|
|
247
|
+
```js
|
|
248
|
+
import { SharedReducer } from 'shared-reducer/frontend';
|
|
249
|
+
import listCommands from 'json-immutability-helper/commands/list';
|
|
250
|
+
import mathCommands from 'json-immutability-helper/commands/math';
|
|
251
|
+
import context from 'json-immutability-helper';
|
|
252
|
+
|
|
253
|
+
const reducer = SharedReducer
|
|
254
|
+
.for('ws://destination', (state) => {})
|
|
255
|
+
.withReducer(context.with(listCommands, mathCommands))
|
|
256
|
+
.build();
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
If you want to use an entirely different reducer, create a wrapper
|
|
260
|
+
and pass it to `withReducer`:
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
import context from 'json-immutability-helper';
|
|
264
|
+
|
|
265
|
+
const myReducer = {
|
|
266
|
+
update: (value, spec) => {
|
|
267
|
+
// return a new value which is the result of applying
|
|
268
|
+
// the given spec to the given value (or throw an error)
|
|
269
|
+
},
|
|
270
|
+
combine: (specs) => {
|
|
271
|
+
// return a new spec which is equivalent to applying
|
|
272
|
+
// all the given specs in order
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// backend
|
|
277
|
+
const broadcaster = Broadcaster.for(new InMemoryModel())
|
|
278
|
+
.withReducer(myReducer)
|
|
279
|
+
.build();
|
|
280
|
+
|
|
281
|
+
// frontend
|
|
282
|
+
const reducer = SharedReducer
|
|
283
|
+
.for('ws://destination', (state) => {})
|
|
284
|
+
.withReducer(myReducer)
|
|
285
|
+
.build();
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Be careful when using your own reducer to avoid introducing
|
|
289
|
+
security vulnerabilities; the functions will be called with
|
|
290
|
+
untrusted input, so should be careful to avoid attacks such
|
|
291
|
+
as code injection or prototype pollution.
|
|
292
|
+
|
|
293
|
+
## Other customisations (Backend)
|
|
294
|
+
|
|
295
|
+
The `Broadcaster` builder has other settable properties:
|
|
296
|
+
|
|
297
|
+
- `withSubscribers`: specify a custom keyed broadcaster, used
|
|
298
|
+
for communicating changes to all consumers. Required interface:
|
|
299
|
+
|
|
300
|
+
```js
|
|
301
|
+
{
|
|
302
|
+
add(key, listener) {
|
|
303
|
+
// add the listener function to key
|
|
304
|
+
},
|
|
305
|
+
remove(key, listener) {
|
|
306
|
+
// remove the listener function from key
|
|
307
|
+
},
|
|
308
|
+
broadcast(key, message) {
|
|
309
|
+
// call all current listener functions for key with
|
|
310
|
+
// the parameter message
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
All functions can be asynchronous or synchronous.
|
|
316
|
+
|
|
317
|
+
The main use-case for overriding this would be to share
|
|
318
|
+
messages between multiple servers for load balancing, but
|
|
319
|
+
note that in most cases you probably want to load balance
|
|
320
|
+
_documents_ rather than _users_ for better scalability.
|
|
321
|
+
|
|
322
|
+
- `withTaskQueues`: specify a custom task queue, used to ensure
|
|
323
|
+
operations happen in the correct order. Required interface:
|
|
324
|
+
|
|
325
|
+
```js
|
|
326
|
+
{
|
|
327
|
+
push(key, task) {
|
|
328
|
+
// add the (possibly asynchronous) task to the queue
|
|
329
|
+
// for the given key
|
|
330
|
+
},
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
The default implementation will execute the task if it is
|
|
335
|
+
the first task in a particular queue. If there is already
|
|
336
|
+
a task in the queue, it will be stored and executed once
|
|
337
|
+
the existing tasks have finished. Once all tasks for a
|
|
338
|
+
particular key have finished, it will remove the queue.
|
|
339
|
+
|
|
340
|
+
As with `withSubscribers`, the main reason to override
|
|
341
|
+
this is to provide consistency if multiple servers are
|
|
342
|
+
able to modify the same document simultaneously.
|
|
343
|
+
|
|
344
|
+
- `withIdProvider`: specify a custom unique ID provider.
|
|
345
|
+
Required interface:
|
|
346
|
+
|
|
347
|
+
```js
|
|
348
|
+
{
|
|
349
|
+
get() {
|
|
350
|
+
// return a unique string (must be synchronous)
|
|
351
|
+
},
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
The returned ID is used internally and passed through
|
|
356
|
+
the configured `taskQueues` to identify the source of
|
|
357
|
+
a change. It is not revealed to users. The default
|
|
358
|
+
implementation uses a fixed random prefix followed by
|
|
359
|
+
an incrementing number, which should be sufficient for
|
|
360
|
+
most use cases.
|
|
361
|
+
|
|
362
|
+
## Older versions
|
|
363
|
+
|
|
364
|
+
For older versions of this library, see the separate
|
|
365
|
+
[backend](https://github.com/davidje13/shared-reducer-backend) and
|
|
366
|
+
[frontend](https://github.com/davidje13/shared-reducer-frontend) repositories.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
declare class UniqueIdProvider {
|
|
2
|
+
private readonly _shared;
|
|
3
|
+
private _unique;
|
|
4
|
+
get(): string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
type Task<T> = () => Promise<T>;
|
|
8
|
+
interface TaskQueue<T> extends EventTarget {
|
|
9
|
+
push(task: Task<T>): Promise<T>;
|
|
10
|
+
}
|
|
11
|
+
type TaskQueueFactory<T> = () => TaskQueue<T>;
|
|
12
|
+
|
|
13
|
+
declare class TaskQueueMap<T> {
|
|
14
|
+
private readonly _queueFactory;
|
|
15
|
+
private readonly _queues;
|
|
16
|
+
constructor(_queueFactory?: () => TaskQueue<T>);
|
|
17
|
+
push(key: string, task: Task<T>): Promise<T>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type TopicListener<T> = (message: T) => void;
|
|
21
|
+
interface Topic<T> {
|
|
22
|
+
add(fn: TopicListener<T>): Promise<void> | void;
|
|
23
|
+
remove(fn: TopicListener<T>): Promise<boolean> | boolean;
|
|
24
|
+
broadcast(message: T): Promise<void> | void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface TopicMap<T> {
|
|
28
|
+
add(key: string, fn: TopicListener<T>): Promise<void> | void;
|
|
29
|
+
remove(key: string, fn: TopicListener<T>): Promise<void> | void;
|
|
30
|
+
broadcast(key: string, message: T): Promise<void> | void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
declare class PermissionError extends Error {
|
|
34
|
+
}
|
|
35
|
+
interface Permission<T, SpecT> {
|
|
36
|
+
validateWriteSpec?(spec: SpecT): void;
|
|
37
|
+
validateWrite(newValue: T, oldValue: T): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Model<T> {
|
|
41
|
+
validate(v: unknown): Readonly<T>;
|
|
42
|
+
read(id: string): Promise<Readonly<T> | null | undefined> | Readonly<T> | null | undefined;
|
|
43
|
+
write(id: string, newValue: T, oldValue: T): Promise<void> | void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Context<T, SpecT> {
|
|
47
|
+
update: (input: T, spec: SpecT) => T;
|
|
48
|
+
}
|
|
49
|
+
interface Subscription<T, SpecT, MetaT> {
|
|
50
|
+
getInitialData: () => Readonly<T>;
|
|
51
|
+
send: (change: SpecT, meta?: MetaT) => Promise<void>;
|
|
52
|
+
close: () => Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
type Identifier = string | null;
|
|
55
|
+
type ChangeInfo<SpecT> = {
|
|
56
|
+
change: SpecT;
|
|
57
|
+
error?: undefined;
|
|
58
|
+
} | {
|
|
59
|
+
change?: undefined;
|
|
60
|
+
error: string;
|
|
61
|
+
};
|
|
62
|
+
interface TopicMessage<SpecT> {
|
|
63
|
+
message: ChangeInfo<SpecT>;
|
|
64
|
+
source: Identifier;
|
|
65
|
+
meta?: unknown;
|
|
66
|
+
}
|
|
67
|
+
interface BroadcasterBuilder<T, SpecT> {
|
|
68
|
+
withReducer<SpecT2 extends SpecT>(context: Context<T, SpecT2>): BroadcasterBuilder<T, SpecT2>;
|
|
69
|
+
withSubscribers(subscribers: TopicMap<TopicMessage<SpecT>>): this;
|
|
70
|
+
withTaskQueues(taskQueues: TaskQueueMap<void>): this;
|
|
71
|
+
withIdProvider(idProvider: UniqueIdProvider): this;
|
|
72
|
+
build(): Broadcaster<T, SpecT>;
|
|
73
|
+
}
|
|
74
|
+
declare class Broadcaster<T, SpecT> {
|
|
75
|
+
private readonly _model;
|
|
76
|
+
private readonly _context;
|
|
77
|
+
private readonly _subscribers;
|
|
78
|
+
private readonly _taskQueues;
|
|
79
|
+
private readonly _idProvider;
|
|
80
|
+
private constructor();
|
|
81
|
+
static for<T2>(model: Model<T2>): BroadcasterBuilder<T2, unknown>;
|
|
82
|
+
subscribe<MetaT>(id: string, onChange: (message: ChangeInfo<SpecT>, meta: MetaT | undefined) => void, permission?: Permission<T, SpecT>): Promise<Subscription<T, SpecT, MetaT> | null>;
|
|
83
|
+
update(id: string, change: SpecT, permission?: Permission<T, SpecT>): Promise<void>;
|
|
84
|
+
private _internalApplyChange;
|
|
85
|
+
private _internalQueueChange;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
declare const PING = "P";
|
|
89
|
+
declare const PONG = "p";
|
|
90
|
+
type MaybePromise<T> = Promise<T> | T;
|
|
91
|
+
interface ServerWebSocket {
|
|
92
|
+
on(event: 'close', listener: () => void): void;
|
|
93
|
+
on(event: 'message', listener: (data: unknown, isBinary?: boolean) => void): void;
|
|
94
|
+
send(message: string): void;
|
|
95
|
+
}
|
|
96
|
+
interface WSResponse {
|
|
97
|
+
accept(): Promise<ServerWebSocket>;
|
|
98
|
+
sendError(httpStatus: number): void;
|
|
99
|
+
beginTransaction(): void;
|
|
100
|
+
endTransaction(): void;
|
|
101
|
+
}
|
|
102
|
+
declare const websocketHandler: <T, SpecT>(broadcaster: Broadcaster<T, SpecT>) => <Req, Res extends WSResponse>(idGetter: (req: Req, res: Res) => MaybePromise<string>, permissionGetter: (req: Req, res: Res) => MaybePromise<Permission<T, SpecT>>) => (req: Req, res: Res) => Promise<void>;
|
|
103
|
+
|
|
104
|
+
interface Collection<T> {
|
|
105
|
+
get<K extends keyof T & string>(searchAttribute: K, searchValue: T[K]): Promise<Readonly<T> | null>;
|
|
106
|
+
update<K extends keyof T & string>(searchAttribute: K, searchValue: T[K], update: Partial<T>): Promise<void>;
|
|
107
|
+
}
|
|
108
|
+
declare class CollectionStorageModel<T extends object> implements Model<T> {
|
|
109
|
+
private readonly _collection;
|
|
110
|
+
private readonly _idCol;
|
|
111
|
+
readonly validate: (v: unknown) => T;
|
|
112
|
+
private readonly _readErrorIntercept;
|
|
113
|
+
private readonly _writeErrorIntercept;
|
|
114
|
+
constructor(_collection: Collection<T>, _idCol: keyof T & string, validate: (v: unknown) => T, _readErrorIntercept?: (e: Error) => Error, _writeErrorIntercept?: (e: Error) => Error);
|
|
115
|
+
read(id: string): Promise<Readonly<T> | null>;
|
|
116
|
+
write(id: string, newValue: T, oldValue: T): Promise<void>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
declare class InMemoryModel<T> implements Model<T> {
|
|
120
|
+
readonly read: (id: string) => Readonly<T> | undefined;
|
|
121
|
+
readonly validate: (v: unknown) => T;
|
|
122
|
+
private readonly _memory;
|
|
123
|
+
constructor(validator?: (x: unknown) => T);
|
|
124
|
+
set(id: string, value: T): void;
|
|
125
|
+
get(id: string): Readonly<T> | undefined;
|
|
126
|
+
delete(id: string): void;
|
|
127
|
+
write(id: string, newValue: T, oldValue: T): void;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
declare const ReadOnly: Permission<unknown, unknown>;
|
|
131
|
+
|
|
132
|
+
declare const ReadWrite: Permission<unknown, unknown>;
|
|
133
|
+
|
|
134
|
+
declare class ReadWriteStruct<T extends object> implements Permission<T, unknown> {
|
|
135
|
+
private readonly _readOnlyFields;
|
|
136
|
+
constructor(_readOnlyFields?: (keyof T)[]);
|
|
137
|
+
validateWrite(newValue: T, oldValue: T): void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
declare class AsyncTaskQueue<T> extends EventTarget implements TaskQueue<T> {
|
|
141
|
+
private readonly _queue;
|
|
142
|
+
private _running;
|
|
143
|
+
push(task: Task<T>): Promise<T>;
|
|
144
|
+
private _internalConsumeQueue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
declare class InMemoryTopic<T> implements Topic<T> {
|
|
148
|
+
private readonly _subscribers;
|
|
149
|
+
add(fn: TopicListener<T>): void;
|
|
150
|
+
remove(fn: TopicListener<T>): boolean;
|
|
151
|
+
broadcast(message: T): void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
declare class TrackingTopicMap<T> implements TopicMap<T> {
|
|
155
|
+
private readonly _topicFactory;
|
|
156
|
+
private readonly _data;
|
|
157
|
+
constructor(_topicFactory: (key: string) => Topic<T>);
|
|
158
|
+
add(key: string, fn: TopicListener<T>): Promise<void>;
|
|
159
|
+
remove(key: string, fn: TopicListener<T>): Promise<void>;
|
|
160
|
+
broadcast(key: string, message: T): Promise<void>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export { AsyncTaskQueue, Broadcaster, type ChangeInfo, CollectionStorageModel, type Context, InMemoryModel, InMemoryTopic, type Model, PING, PONG, type Permission, PermissionError, ReadOnly, ReadWrite, ReadWriteStruct, type Subscription, type Task, type TaskQueue, type TaskQueueFactory, TaskQueueMap, type Topic, type TopicMap, type TopicMessage, TrackingTopicMap, UniqueIdProvider, websocketHandler };
|
package/backend/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";class t{t=crypto.randomUUID().substring(0,8);i=0;get(){const t=this.i;return this.i++,`${this.t}-${t}`}}class e extends EventTarget{o=[];h=!1;push(t){return new Promise(((e,s)=>{this.o.push({task:t,resolve:e,reject:s}),this.h||this.u()}))}async u(){for(this.h=!0;this.o.length>0;){const{task:t,resolve:e,reject:s}=this.o.shift();let r=null,i=!1;try{r=await t(),i=!0}catch(t){s(t)}i&&e(r)}this.h=!1,this.dispatchEvent(new CustomEvent("drain"))}}class s{l;p=new Map;constructor(t=()=>new e){this.l=t}push(t,e){let s=this.p.get(t);return s||(s=this.l(),s.addEventListener("drain",(()=>{this.p.delete(t)})),this.p.set(t,s)),s.push(e)}}class r{v;m=new Map;constructor(t){this.v=t}async add(t,e){let s=this.m.get(t);s||(s=this.v(t),this.m.set(t,s)),await s.add(e)}async remove(t,e){const s=this.m.get(t);if(s){await s.remove(e)||this.m.delete(t)}}async broadcast(t,e){const s=this.m.get(t);s&&await s.broadcast(e)}}class i{_=new Set;add(t){this._.add(t)}remove(t){return this._.delete(t),this._.size>0}broadcast(t){this._.forEach((e=>e(t)))}}const n={validateWrite(){}};class a{O;j;_;C;S;constructor(t,e,s,r,i){this.O=t,this.j=e,this._=s,this.C=r,this.S=i}static for(e){let n,o,c,h;return{withReducer(t){return n=t,this},withSubscribers(t){return o=t,this},withTaskQueues(t){return c=t,this},withIdProvider(t){return h=t,this},build(){if(!n)throw new Error("must set broadcaster context");return new a(e,n,o||new r((()=>new i)),c||new s,h||new t)}}}async subscribe(t,e,s=n){let r=await this.O.read(t);if(null==r)return null;const i=this.S.get(),a=({message:t,source:s,meta:r})=>{s===i?e(t,r):t.change&&e(t,void 0)};return this._.add(t,a),{getInitialData:()=>{if(null===r)throw new Error("Already fetched initialData");const t=r;return r=null,t},send:(e,r)=>this.I(t,e,s,i,r),close:async()=>{await this._.remove(t,a)}}}update(t,e,s=n){return this.I(t,e,s,null,void 0)}async P(t,e,s,r,i){try{const r=await this.O.read(t);if(!r)throw new Error("Deleted");s.validateWriteSpec?.(e);const i=this.j.update(r,e),n=this.O.validate(i);s.validateWrite(n,r),await this.O.write(t,n,r)}catch(e){return void this._.broadcast(t,{message:{error:e instanceof Error?e.message:"Internal error"},source:r,meta:i})}this._.broadcast(t,{message:{change:e},source:r,meta:i})}async I(t,e,s,r,i){return this.C.push(t,(()=>this.P(t,e,s,r,i)))}}function o(t){return"object"==typeof t&&null!==t&&!Array.isArray(t)}function c(t){return function(t){if(!o(t))throw new Error("Must specify change and optional id");const{id:e,change:s}=t;if(!o(s))throw new Error("change must be a dictionary");if(void 0===e)return{change:s};if("number"!=typeof e)throw new Error("if specified, id must be a number");return{change:s,id:e}}(JSON.parse(t))}const h=t=>t;class u extends Error{}const l="Cannot modify data",d={validateWriteSpec(){throw new u(l)},validateWrite(){throw new u(l)}};exports.AsyncTaskQueue=e,exports.Broadcaster=a,exports.CollectionStorageModel=class{$;k;validate;q;M;constructor(t,e,s,r=h,i=h){this.$=t,this.k=e,this.validate=s,this.q=r,this.M=i}async read(t){try{return await this.$.get(this.k,t)}catch(t){throw this.q(t)}}async write(t,e,s){const r={};Object.entries(e).forEach((([t,e])=>{const i=t;e!==(Object.prototype.hasOwnProperty.call(s,i)?s[i]:void 0)&&(r[i]?Object.defineProperty(r,i,{value:e,configurable:!0,enumerable:!0,writable:!0}):r[i]=e)}));try{await this.$.update(this.k,t,r)}catch(t){throw this.M(t)}}},exports.InMemoryModel=class{read=this.get;validate;W=new Map;constructor(t=t=>t){this.validate=t}set(t,e){this.W.set(t,e)}get(t){return this.W.get(t)}delete(t){this.W.delete(t)}write(t,e,s){if(s!==this.W.get(t))throw new Error("Unexpected previous value");this.W.set(t,e)}},exports.InMemoryTopic=i,exports.PING="P",exports.PONG="p",exports.PermissionError=u,exports.ReadOnly=d,exports.ReadWrite=n,exports.ReadWriteStruct=class{A;constructor(t=[]){this.A=t}validateWrite(t,e){Object.keys(e).forEach((e=>{const s=e;if(!Object.prototype.hasOwnProperty.call(t,s)&&this.A.includes(s))throw new u(`Cannot remove field ${s}`)})),Object.keys(t).forEach((s=>{const r=s;if(this.A.includes(r)){if(!Object.prototype.hasOwnProperty.call(e,r))throw new u(`Cannot add field ${r}`);if(t[r]!==e[r])throw new u(`Cannot edit field ${r}`)}}))}},exports.TaskQueueMap=s,exports.TrackingTopicMap=r,exports.UniqueIdProvider=t,exports.websocketHandler=t=>(e,s)=>async(r,i)=>{const n=await i.accept(),a=await e(r,i),o=await s(r,i),h=await t.subscribe(a,((t,e)=>{const s=void 0!==e?{id:e,...t}:t;n.send(JSON.stringify(s))}),o);h?(n.on("close",h.close),n.on("message",((t,e)=>{if(e)return;const s=String(t);if("P"===s)return void n.send("p");const r=c(s);i.beginTransaction(),h.send(r.change,r.id).finally((()=>i.endTransaction()))})),n.send(JSON.stringify({init:h.getInitialData()}))):i.sendError(404)};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
class t{t=crypto.randomUUID().substring(0,8);i=0;get(){const t=this.i;return this.i++,`${this.t}-${t}`}}class e extends EventTarget{o=[];h=!1;push(t){return new Promise(((e,r)=>{this.o.push({task:t,resolve:e,reject:r}),this.h||this.u()}))}async u(){for(this.h=!0;this.o.length>0;){const{task:t,resolve:e,reject:r}=this.o.shift();let s=null,i=!1;try{s=await t(),i=!0}catch(t){r(t)}i&&e(s)}this.h=!1,this.dispatchEvent(new CustomEvent("drain"))}}class r{l;v=new Map;constructor(t=()=>new e){this.l=t}push(t,e){let r=this.v.get(t);return r||(r=this.l(),r.addEventListener("drain",(()=>{this.v.delete(t)})),this.v.set(t,r)),r.push(e)}}class s{m;p=new Map;constructor(t){this.m=t}async add(t,e){let r=this.p.get(t);r||(r=this.m(t),this.p.set(t,r)),await r.add(e)}async remove(t,e){const r=this.p.get(t);if(r){await r.remove(e)||this.p.delete(t)}}async broadcast(t,e){const r=this.p.get(t);r&&await r.broadcast(e)}}class i{_=new Set;add(t){this._.add(t)}remove(t){return this._.delete(t),this._.size>0}broadcast(t){this._.forEach((e=>e(t)))}}const n={validateWrite(){}};class a{O;j;_;C;S;constructor(t,e,r,s,i){this.O=t,this.j=e,this._=r,this.C=s,this.S=i}static for(e){let n,o,c,h;return{withReducer(t){return n=t,this},withSubscribers(t){return o=t,this},withTaskQueues(t){return c=t,this},withIdProvider(t){return h=t,this},build(){if(!n)throw new Error("must set broadcaster context");return new a(e,n,o||new s((()=>new i)),c||new r,h||new t)}}}async subscribe(t,e,r=n){let s=await this.O.read(t);if(null==s)return null;const i=this.S.get(),a=({message:t,source:r,meta:s})=>{r===i?e(t,s):t.change&&e(t,void 0)};return this._.add(t,a),{getInitialData:()=>{if(null===s)throw new Error("Already fetched initialData");const t=s;return s=null,t},send:(e,s)=>this.I(t,e,r,i,s),close:async()=>{await this._.remove(t,a)}}}update(t,e,r=n){return this.I(t,e,r,null,void 0)}async P(t,e,r,s,i){try{const s=await this.O.read(t);if(!s)throw new Error("Deleted");r.validateWriteSpec?.(e);const i=this.j.update(s,e),n=this.O.validate(i);r.validateWrite(n,s),await this.O.write(t,n,s)}catch(e){return void this._.broadcast(t,{message:{error:e instanceof Error?e.message:"Internal error"},source:s,meta:i})}this._.broadcast(t,{message:{change:e},source:s,meta:i})}async I(t,e,r,s,i){return this.C.push(t,(()=>this.P(t,e,r,s,i)))}}function o(t){return"object"==typeof t&&null!==t&&!Array.isArray(t)}function c(t){return function(t){if(!o(t))throw new Error("Must specify change and optional id");const{id:e,change:r}=t;if(!o(r))throw new Error("change must be a dictionary");if(void 0===e)return{change:r};if("number"!=typeof e)throw new Error("if specified, id must be a number");return{change:r,id:e}}(JSON.parse(t))}const h="P",u="p",l=t=>(e,r)=>async(s,i)=>{const n=await i.accept(),a=await e(s,i),o=await r(s,i),h=await t.subscribe(a,((t,e)=>{const r=void 0!==e?{id:e,...t}:t;n.send(JSON.stringify(r))}),o);h?(n.on("close",h.close),n.on("message",((t,e)=>{if(e)return;const r=String(t);if("P"===r)return void n.send("p");const s=c(r);i.beginTransaction(),h.send(s.change,s.id).finally((()=>i.endTransaction()))})),n.send(JSON.stringify({init:h.getInitialData()}))):i.sendError(404)},d=t=>t;class w{$;k;validate;q;M;constructor(t,e,r,s=d,i=d){this.$=t,this.k=e,this.validate=r,this.q=s,this.M=i}async read(t){try{return await this.$.get(this.k,t)}catch(t){throw this.q(t)}}async write(t,e,r){const s={};Object.entries(e).forEach((([t,e])=>{const i=t;e!==(Object.prototype.hasOwnProperty.call(r,i)?r[i]:void 0)&&(s[i]?Object.defineProperty(s,i,{value:e,configurable:!0,enumerable:!0,writable:!0}):s[i]=e)}));try{await this.$.update(this.k,t,s)}catch(t){throw this.M(t)}}}class f extends Error{}class y{read=this.get;validate;W=new Map;constructor(t=t=>t){this.validate=t}set(t,e){this.W.set(t,e)}get(t){return this.W.get(t)}delete(t){this.W.delete(t)}write(t,e,r){if(r!==this.W.get(t))throw new Error("Unexpected previous value");this.W.set(t,e)}}const b="Cannot modify data",v={validateWriteSpec(){throw new f(b)},validateWrite(){throw new f(b)}};class m{A;constructor(t=[]){this.A=t}validateWrite(t,e){Object.keys(e).forEach((e=>{const r=e;if(!Object.prototype.hasOwnProperty.call(t,r)&&this.A.includes(r))throw new f(`Cannot remove field ${r}`)})),Object.keys(t).forEach((r=>{const s=r;if(this.A.includes(s)){if(!Object.prototype.hasOwnProperty.call(e,s))throw new f(`Cannot add field ${s}`);if(t[s]!==e[s])throw new f(`Cannot edit field ${s}`)}}))}}export{e as AsyncTaskQueue,a as Broadcaster,w as CollectionStorageModel,y as InMemoryModel,i as InMemoryTopic,h as PING,u as PONG,f as PermissionError,v as ReadOnly,n as ReadWrite,m as ReadWriteStruct,r as TaskQueueMap,s as TrackingTopicMap,t as UniqueIdProvider,l as websocketHandler};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
interface Context<T, SpecT> {
|
|
2
|
+
update: (input: T, spec: SpecT) => T;
|
|
3
|
+
combine: (specs: SpecT[]) => SpecT;
|
|
4
|
+
}
|
|
5
|
+
declare class SyncCallback<T> {
|
|
6
|
+
readonly sync: (state: T) => void;
|
|
7
|
+
readonly reject: (message: string) => void;
|
|
8
|
+
constructor(sync: (state: T) => void, reject: (message: string) => void);
|
|
9
|
+
}
|
|
10
|
+
type SpecGenerator<T, SpecT> = (state: T) => SpecSource<T, SpecT>[] | null | undefined;
|
|
11
|
+
type SpecSource<T, SpecT> = SpecT | SpecGenerator<T, SpecT> | SyncCallback<T> | null | undefined;
|
|
12
|
+
type DispatchSpec<T, SpecT> = SpecSource<T, SpecT>[];
|
|
13
|
+
type Dispatch<T, SpecT> = (specs: DispatchSpec<T, SpecT> | null | undefined) => void;
|
|
14
|
+
|
|
15
|
+
declare function actionsHandledCallback<T>(callback?: (state: T) => void): ((state: T) => null) | null;
|
|
16
|
+
|
|
17
|
+
declare function actionsSyncedCallback<T>(resolve?: (state: T) => void, reject?: (message: string) => void): SyncCallback<T> | null;
|
|
18
|
+
|
|
19
|
+
interface SharedReducerBuilder<T, SpecT> {
|
|
20
|
+
withReducer<SpecT2 extends SpecT>(context: Context<T, SpecT2>): SharedReducerBuilder<T, SpecT2>;
|
|
21
|
+
withToken(token: string): this;
|
|
22
|
+
withErrorHandler(handler: (error: string) => void): this;
|
|
23
|
+
withWarningHandler(handler: (error: string) => void): this;
|
|
24
|
+
build(): SharedReducer<T, SpecT>;
|
|
25
|
+
}
|
|
26
|
+
declare class SharedReducer<T, SpecT> {
|
|
27
|
+
private readonly _context;
|
|
28
|
+
private readonly _changeHandler;
|
|
29
|
+
private readonly _warningHandler;
|
|
30
|
+
private readonly _connection;
|
|
31
|
+
private _latestStates;
|
|
32
|
+
private _currentChange;
|
|
33
|
+
private _currentSyncCallbacks;
|
|
34
|
+
private _localChanges;
|
|
35
|
+
private _pendingChanges;
|
|
36
|
+
private readonly _dispatchLock;
|
|
37
|
+
private readonly _nextId;
|
|
38
|
+
private constructor();
|
|
39
|
+
static for<T2>(wsUrl: string, changeHandler?: (state: T2) => void): SharedReducerBuilder<T2, unknown>;
|
|
40
|
+
close(): void;
|
|
41
|
+
dispatch: Dispatch<T, SpecT>;
|
|
42
|
+
addSyncCallback(resolve: (state: T) => void, reject?: (message: string) => void): void;
|
|
43
|
+
syncedState(): Promise<T>;
|
|
44
|
+
getState(): T | undefined;
|
|
45
|
+
private _sendCurrentChange;
|
|
46
|
+
private _addCurrentChange;
|
|
47
|
+
private _applySpecs;
|
|
48
|
+
private _popLocalChange;
|
|
49
|
+
private _handleErrorMessage;
|
|
50
|
+
private _handleInitMessage;
|
|
51
|
+
private _handleChangeMessage;
|
|
52
|
+
private _handleMessage;
|
|
53
|
+
private _computeLocal;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { type Context, type Dispatch, type DispatchSpec, SharedReducer, actionsHandledCallback, actionsSyncedCallback };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";class t{sync;reject;constructor(t,s){this.sync=t,this.reject=s}}const s=()=>null;function i(i,e){return i||e?new t(i||s,e||s):null}class e{t;h;l;o=null;constructor(t,s=void 0,i,e=void 0){this.t=i,this.h=e,this.l=new WebSocket(t),this.l.addEventListener("message",this.u),this.l.addEventListener("error",this._),this.l.addEventListener("close",this.p),s&&this.l.addEventListener("open",(()=>this.l.send(s)),{once:!0}),this.C()}send(t){this.l.send(JSON.stringify(t))}close(){this.l.close(),null!==this.o&&clearTimeout(this.o)}C(){null!==this.o&&clearTimeout(this.o),this.o=setTimeout(this.v,2e4)}v=()=>{this.l.send("P")};u=({data:t})=>{this.C(),"p"!==t&&this.t(JSON.parse(t))};_=()=>{this.h?.("Failed to connect")};p=()=>{null!==this.o&&clearTimeout(this.o)}}class n{m;S;k;I;O=null;P;T=[];j=[];M=[];N=function(t){let s=!1;return i=>{if(s)throw new Error(t);try{return s=!0,i()}finally{s=!1}}}("Cannot dispatch recursively");H=function(){let t=1;return()=>t++}();constructor(t,s,i,n,h,r){this.m=t,this.S=n,this.k=r,this.I=new e(s,i,this.u,h)}static for(t,s){let i,e,h,r;return{withReducer(t){return i=t,this},withToken(t){return e=t,this},withErrorHandler(t){return h=t,this},withWarningHandler(t){return r=t,this},build(){if(!i)throw new Error("must set broadcaster context");return new n(i,t,e,s,h,r)}}}close(){this.I.close(),this.O=null,this.P=void 0,this.T=[],this.j=[],this.M=[]}dispatch=t=>{if(t&&t.length)if(this.O){const s=this.J(this.O,t);s!==this.O&&(this.O=s,this.S?.(s.local))}else this.M.push(...t)};addSyncCallback(t,s){this.dispatch([i(t,s)])}syncedState(){return new Promise(((t,s)=>{this.addSyncCallback(t,s)}))}getState(){return this.O?.local}$=()=>{if(void 0===this.P)return;const t=this.H(),s=this.P,i=this.T;this.P=void 0,this.T=[],this.j.push({change:s,id:t,syncCallbacks:i}),this.I.send({change:s,id:t})};A(t){void 0===this.P?(this.P=t,setTimeout(this.$,0)):this.P=this.m.combine([this.P,t])}J(s,i){if(!i.length)return s;const{state:e,delta:n}=this.N((()=>function(s,i,e,n){let h=i;const r=[],l=[];function o(){if(l.length>0){const t=s.combine(l);r.push(t),h=s.update(h,t),l.length=0}}return function(t,s){let i={vs:t,i:0,prev:null};for(;i;)if(i.i>=i.vs.length)i=i.prev;else{const t=s(i.vs[i.i]);i.i+=1,t&&t.length&&(i={vs:t,i:0,prev:i})}}(e,(s=>s instanceof t?(o(),n(s,h),null):"function"==typeof s?(o(),s(h)):(s&&l.push(s),null))),o(),{state:h,delta:s.combine(r)}}(this.m,s.local,i,((t,i)=>{i===s.local&&void 0===this.P?t.sync(s.local):this.T.push(t)}))));return e===s.local?s:(this.A(n),{server:s.server,local:e})}L(t){const s=void 0===t?-1:this.j.findIndex((s=>s.id===t));return-1===s?{localChange:null,index:s}:{localChange:this.j.splice(s,1)[0],index:s}}W(t){const{localChange:s}=this.L(t.id);s?(this.k?.(`API rejected update: ${t.error}`),this.O&&(this.O=this.q(this.O.server),this.S?.(this.O.local)),s.syncCallbacks.forEach((s=>s.reject(t.error)))):this.k?.(`API sent error: ${t.error}`)}F(t){this.O=this.J(this.q(t.init),this.M),this.M.length=0,this.S?.(this.O.local)}R(t){if(!this.O)return void this.k?.(`Ignoring change before init: ${JSON.stringify(t)}`);const{localChange:s,index:i}=this.L(t.id),e=this.m.update(this.O.server,t.change);0===i?this.O={server:e,local:this.O.local}:(this.O=this.q(e),this.S?.(this.O.local));const n=this.O.local;s?.syncCallbacks.forEach((t=>t.sync(n)))}u=t=>{Object.prototype.hasOwnProperty.call(t,"change")?this.R(t):Object.prototype.hasOwnProperty.call(t,"init")?this.F(t):Object.prototype.hasOwnProperty.call(t,"error")?this.W(t):this.k?.(`Ignoring unknown API message: ${JSON.stringify(t)}`)};q(t){let s=t;if(this.j.length>0){const t=this.m.combine(this.j.map((({change:t})=>t)));s=this.m.update(s,t)}return void 0!==this.P&&(s=this.m.update(s,this.P)),{server:t,local:s}}}exports.SharedReducer=n,exports.actionsHandledCallback=function(t){return t?s=>(t(s),null):null},exports.actionsSyncedCallback=i;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
function t(t){return t?s=>(t(s),null):null}class s{sync;reject;constructor(t,s){this.sync=t,this.reject=s}}const i=()=>null;function e(t,e){return t||e?new s(t||i,e||i):null}class n{t;h;l;o=null;constructor(t,s=void 0,i,e=void 0){this.t=i,this.h=e,this.l=new WebSocket(t),this.l.addEventListener("message",this.u),this.l.addEventListener("error",this._),this.l.addEventListener("close",this.C),s&&this.l.addEventListener("open",(()=>this.l.send(s)),{once:!0}),this.p()}send(t){this.l.send(JSON.stringify(t))}close(){this.l.close(),null!==this.o&&clearTimeout(this.o)}p(){null!==this.o&&clearTimeout(this.o),this.o=setTimeout(this.v,2e4)}v=()=>{this.l.send("P")};u=({data:t})=>{this.p(),"p"!==t&&this.t(JSON.parse(t))};_=()=>{this.h?.("Failed to connect")};C=()=>{null!==this.o&&clearTimeout(this.o)}}class h{m;S;k;I;O=null;P;T=[];j=[];M=[];N=function(t){let s=!1;return i=>{if(s)throw new Error(t);try{return s=!0,i()}finally{s=!1}}}("Cannot dispatch recursively");H=function(){let t=1;return()=>t++}();constructor(t,s,i,e,h,r){this.m=t,this.S=e,this.k=r,this.I=new n(s,i,this.u,h)}static for(t,s){let i,e,n,r;return{withReducer(t){return i=t,this},withToken(t){return e=t,this},withErrorHandler(t){return n=t,this},withWarningHandler(t){return r=t,this},build(){if(!i)throw new Error("must set broadcaster context");return new h(i,t,e,s,n,r)}}}close(){this.I.close(),this.O=null,this.P=void 0,this.T=[],this.j=[],this.M=[]}dispatch=t=>{if(t&&t.length)if(this.O){const s=this.J(this.O,t);s!==this.O&&(this.O=s,this.S?.(s.local))}else this.M.push(...t)};addSyncCallback(t,s){this.dispatch([e(t,s)])}syncedState(){return new Promise(((t,s)=>{this.addSyncCallback(t,s)}))}getState(){return this.O?.local}$=()=>{if(void 0===this.P)return;const t=this.H(),s=this.P,i=this.T;this.P=void 0,this.T=[],this.j.push({change:s,id:t,syncCallbacks:i}),this.I.send({change:s,id:t})};A(t){void 0===this.P?(this.P=t,setTimeout(this.$,0)):this.P=this.m.combine([this.P,t])}J(t,i){if(!i.length)return t;const{state:e,delta:n}=this.N((()=>function(t,i,e,n){let h=i;const r=[],l=[];function o(){if(l.length>0){const s=t.combine(l);r.push(s),h=t.update(h,s),l.length=0}}return function(t,s){let i={vs:t,i:0,prev:null};for(;i;)if(i.i>=i.vs.length)i=i.prev;else{const t=s(i.vs[i.i]);i.i+=1,t&&t.length&&(i={vs:t,i:0,prev:i})}}(e,(t=>t instanceof s?(o(),n(t,h),null):"function"==typeof t?(o(),t(h)):(t&&l.push(t),null))),o(),{state:h,delta:t.combine(r)}}(this.m,t.local,i,((s,i)=>{i===t.local&&void 0===this.P?s.sync(t.local):this.T.push(s)}))));return e===t.local?t:(this.A(n),{server:t.server,local:e})}L(t){const s=void 0===t?-1:this.j.findIndex((s=>s.id===t));return-1===s?{localChange:null,index:s}:{localChange:this.j.splice(s,1)[0],index:s}}W(t){const{localChange:s}=this.L(t.id);s?(this.k?.(`API rejected update: ${t.error}`),this.O&&(this.O=this.q(this.O.server),this.S?.(this.O.local)),s.syncCallbacks.forEach((s=>s.reject(t.error)))):this.k?.(`API sent error: ${t.error}`)}F(t){this.O=this.J(this.q(t.init),this.M),this.M.length=0,this.S?.(this.O.local)}R(t){if(!this.O)return void this.k?.(`Ignoring change before init: ${JSON.stringify(t)}`);const{localChange:s,index:i}=this.L(t.id),e=this.m.update(this.O.server,t.change);0===i?this.O={server:e,local:this.O.local}:(this.O=this.q(e),this.S?.(this.O.local));const n=this.O.local;s?.syncCallbacks.forEach((t=>t.sync(n)))}u=t=>{Object.prototype.hasOwnProperty.call(t,"change")?this.R(t):Object.prototype.hasOwnProperty.call(t,"init")?this.F(t):Object.prototype.hasOwnProperty.call(t,"error")?this.W(t):this.k?.(`Ignoring unknown API message: ${JSON.stringify(t)}`)};q(t){let s=t;if(this.j.length>0){const t=this.m.combine(this.j.map((({change:t})=>t)));s=this.m.update(s,t)}return void 0!==this.P&&(s=this.m.update(s,this.P)),{server:t,local:s}}}export{h as SharedReducer,t as actionsHandledCallback,e as actionsSyncedCallback};
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shared-reducer",
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "shared state management",
|
|
5
|
+
"author": "David Evans",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"reducer",
|
|
9
|
+
"websocket"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
"./backend": {
|
|
13
|
+
"import": {
|
|
14
|
+
"default": "./backend/index.mjs",
|
|
15
|
+
"types": "./backend/index.d.ts"
|
|
16
|
+
},
|
|
17
|
+
"require": {
|
|
18
|
+
"default": "./backend/index.js",
|
|
19
|
+
"types": "./backend/index.d.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"./frontend": {
|
|
23
|
+
"import": {
|
|
24
|
+
"default": "./frontend/index.mjs",
|
|
25
|
+
"types": "./frontend/index.d.ts"
|
|
26
|
+
},
|
|
27
|
+
"require": {
|
|
28
|
+
"default": "./frontend/index.js",
|
|
29
|
+
"types": "./frontend/index.d.ts"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"format": "prettier --write .",
|
|
35
|
+
"test": "lean-test --preprocess=tsc --parallel-suites && package/build.sh && package/run.sh && tsc && prettier --check .",
|
|
36
|
+
"dopublish": "package/build.sh && npm publish package.tgz"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/davidje13/shared-reducer.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/davidje13/shared-reducer/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/davidje13/shared-reducer#readme",
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@rollup/plugin-terser": "0.4.x",
|
|
48
|
+
"@rollup/plugin-typescript": "11.x",
|
|
49
|
+
"collection-storage": "3.x",
|
|
50
|
+
"json-immutability-helper": "3.x",
|
|
51
|
+
"lean-test": "2.x",
|
|
52
|
+
"prettier": "3.3.3",
|
|
53
|
+
"rollup": "4.x",
|
|
54
|
+
"rollup-plugin-dts": "6.x",
|
|
55
|
+
"superwstest": "2.x",
|
|
56
|
+
"tslib": "2.7.x",
|
|
57
|
+
"typescript": "5.5.x",
|
|
58
|
+
"websocket-express": "3.x"
|
|
59
|
+
}
|
|
60
|
+
}
|