shared-reducer 4.0.2 → 5.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.
- package/README.md +196 -164
- package/backend/index.d.ts +78 -57
- package/backend/index.js +1 -1
- package/backend/index.mjs +1 -1
- package/frontend/index.d.ts +93 -41
- package/frontend/index.js +1 -1
- package/frontend/index.mjs +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -15,16 +15,15 @@ npm install --save shared-reducer json-immutability-helper
|
|
|
15
15
|
|
|
16
16
|
## Usage (Backend)
|
|
17
17
|
|
|
18
|
-
This project is compatible with
|
|
19
|
-
[websocket-express](https://github.com/davidje13/websocket-express),
|
|
18
|
+
This project is compatible with [websocket-express](https://github.com/davidje13/websocket-express),
|
|
20
19
|
but can also be used in isolation.
|
|
21
20
|
|
|
22
21
|
### With websocket-express
|
|
23
22
|
|
|
24
|
-
```
|
|
23
|
+
```javascript
|
|
25
24
|
import {
|
|
26
25
|
Broadcaster,
|
|
27
|
-
|
|
26
|
+
WebsocketHandlerFactory,
|
|
28
27
|
InMemoryModel,
|
|
29
28
|
ReadWrite,
|
|
30
29
|
} from 'shared-reducer/backend';
|
|
@@ -32,60 +31,57 @@ import context from 'json-immutability-helper';
|
|
|
32
31
|
import { WebSocketExpress } from 'websocket-express';
|
|
33
32
|
|
|
34
33
|
const model = new InMemoryModel();
|
|
35
|
-
const broadcaster = Broadcaster
|
|
36
|
-
.withReducer(context)
|
|
37
|
-
.build();
|
|
34
|
+
const broadcaster = new Broadcaster(model, context);
|
|
38
35
|
model.set('a', { foo: 'v1' });
|
|
39
36
|
|
|
40
37
|
const app = new WebSocketExpress();
|
|
41
38
|
const server = app.listen(0, 'localhost');
|
|
42
39
|
|
|
43
|
-
const
|
|
44
|
-
app.ws('/:id', handler((req) => req.params.id, () => ReadWrite));
|
|
40
|
+
const handlerFactory = new WebsocketHandlerFactory(broadcaster);
|
|
41
|
+
app.ws('/:id', handlerFactory.handler((req) => req.params.id, () => ReadWrite));
|
|
42
|
+
|
|
43
|
+
const server = app.listen();
|
|
44
|
+
|
|
45
|
+
// later, to shutdown gracefully:
|
|
46
|
+
// send a close signal to all clients and wait up to 1 second for acknowledgement:
|
|
47
|
+
await handlerFactory.close(1000);
|
|
48
|
+
server.close();
|
|
45
49
|
```
|
|
46
50
|
|
|
47
|
-
For real use-cases, you will probably want to add authentication middleware
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
For real use-cases, you will probably want to add authentication middleware to the expressjs chain,
|
|
52
|
+
and you may want to give some users read-only and others read-write access, which can be achieved in
|
|
53
|
+
the second lambda.
|
|
50
54
|
|
|
51
55
|
### Alone
|
|
52
56
|
|
|
53
|
-
```
|
|
57
|
+
```javascript
|
|
54
58
|
import { Broadcaster, InMemoryModel } from 'shared-reducer/backend';
|
|
55
59
|
import context from 'json-immutability-helper';
|
|
56
60
|
|
|
57
61
|
const model = new InMemoryModel();
|
|
58
|
-
const broadcaster = Broadcaster
|
|
59
|
-
.withReducer(context)
|
|
60
|
-
.build();
|
|
62
|
+
const broadcaster = new Broadcaster(model, context);
|
|
61
63
|
model.set('a', { foo: 'v1' });
|
|
62
64
|
|
|
63
65
|
// ...
|
|
64
66
|
|
|
65
|
-
const subscription = await broadcaster.subscribe(
|
|
66
|
-
'a',
|
|
67
|
-
(change, meta) => { /*...*/ },
|
|
68
|
-
);
|
|
67
|
+
const subscription = await broadcaster.subscribe('a');
|
|
69
68
|
|
|
70
69
|
const begin = subscription.getInitialData();
|
|
70
|
+
subscription.listen((change, meta) => { /*...*/ });
|
|
71
71
|
await subscription.send(['=', { foo: 'v2' }]);
|
|
72
72
|
// callback provided earlier is invoked
|
|
73
73
|
|
|
74
74
|
await subscription.close();
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
### Persisting data
|
|
78
78
|
|
|
79
79
|
A convenience wrapper is provided for use with
|
|
80
|
-
[collection-storage](https://github.com/davidje13/collection-storage),
|
|
81
|
-
|
|
82
|
-
link any backend.
|
|
80
|
+
[collection-storage](https://github.com/davidje13/collection-storage), or you can write your own
|
|
81
|
+
implementation of the `Model` interface to link any backend.
|
|
83
82
|
|
|
84
|
-
```
|
|
85
|
-
import {
|
|
86
|
-
Broadcaster,
|
|
87
|
-
CollectionStorageModel,
|
|
88
|
-
} from 'shared-reducer/backend';
|
|
83
|
+
```javascript
|
|
84
|
+
import { Broadcaster, CollectionStorageModel } from 'shared-reducer/backend';
|
|
89
85
|
import context from 'json-immutability-helper';
|
|
90
86
|
import CollectionStorage from 'collection-storage';
|
|
91
87
|
|
|
@@ -97,29 +93,38 @@ const model = new CollectionStorageModel(
|
|
|
97
93
|
// or throws if invalid (protects stored data from malicious changes)
|
|
98
94
|
MY_VALIDATOR,
|
|
99
95
|
);
|
|
100
|
-
const broadcaster = Broadcaster
|
|
101
|
-
.withReducer(context)
|
|
102
|
-
.build();
|
|
96
|
+
const broadcaster = new Broadcaster(model, context);
|
|
103
97
|
```
|
|
104
98
|
|
|
105
|
-
Note that the provided validator MUST verify structural integrity (e.g.
|
|
106
|
-
|
|
99
|
+
Note that the provided validator MUST verify structural integrity (e.g. ensuring no unexpected
|
|
100
|
+
fields are added or types are changed).
|
|
107
101
|
|
|
108
102
|
## Usage (Frontend)
|
|
109
103
|
|
|
110
104
|
```javascript
|
|
111
|
-
import { SharedReducer
|
|
105
|
+
import { SharedReducer } from 'shared-reducer/frontend';
|
|
112
106
|
import context from 'json-immutability-helper';
|
|
113
107
|
|
|
114
|
-
const reducer = SharedReducer
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
108
|
+
const reducer = new SharedReducer(context, () => ({
|
|
109
|
+
url: 'ws://destination',
|
|
110
|
+
token: 'my-token',
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
reducer.addStateListener((state) => {
|
|
114
|
+
console.log('latest state is', state);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
reducer.addEventListener('connected', () => {
|
|
118
|
+
console.log('connected / reconnected');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
reducer.addEventListener('disconnected', (e) => {
|
|
122
|
+
console.log('connection lost', e.detail.code, e.detail.reason);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
reducer.addEventListener('warning', (e) => {
|
|
126
|
+
console.log('latest change failed', e.detail);
|
|
127
|
+
});
|
|
123
128
|
|
|
124
129
|
const dispatch = reducer.dispatch;
|
|
125
130
|
|
|
@@ -129,23 +134,22 @@ dispatch([
|
|
|
129
134
|
|
|
130
135
|
dispatch([
|
|
131
136
|
(state) => {
|
|
132
|
-
return {
|
|
133
|
-
a: ['=', Math.pow(2, state.a)],
|
|
134
|
-
};
|
|
137
|
+
return [{ a: ['=', Math.pow(2, state.a)] }];
|
|
135
138
|
},
|
|
136
139
|
]);
|
|
137
140
|
|
|
138
141
|
dispatch([
|
|
139
|
-
|
|
142
|
+
(state) => {
|
|
140
143
|
console.log('state after handling is', state);
|
|
141
|
-
|
|
144
|
+
return [];
|
|
145
|
+
},
|
|
142
146
|
]);
|
|
143
147
|
|
|
144
|
-
dispatch(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
148
|
+
dispatch(
|
|
149
|
+
[{ a: ['add', 1] }],
|
|
150
|
+
(state) => console.log('state after syncing is', state),
|
|
151
|
+
(message) => console.warn('failed to sync', message),
|
|
152
|
+
);
|
|
149
153
|
|
|
150
154
|
dispatch([
|
|
151
155
|
{ a: ['add', 1] },
|
|
@@ -155,8 +159,7 @@ dispatch([
|
|
|
155
159
|
|
|
156
160
|
### Specs
|
|
157
161
|
|
|
158
|
-
The specs need to match whichever reducer you are using. In the examples
|
|
159
|
-
above, that is
|
|
162
|
+
The specs need to match whichever reducer you are using. In the examples above, that is
|
|
160
163
|
[json-immutability-helper](https://github.com/davidje13/json-immutability-helper).
|
|
161
164
|
|
|
162
165
|
## WebSocket protocol
|
|
@@ -165,101 +168,86 @@ The websocket protocol is minimal:
|
|
|
165
168
|
|
|
166
169
|
### Client-to-server
|
|
167
170
|
|
|
168
|
-
`<token>`:
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
171
|
+
- `<token>`: The authentication token is sent as the first message when the connection is
|
|
172
|
+
established. This is plaintext. The server should respond by either terminating the connection (if
|
|
173
|
+
the token is deemed invalid), or with an `init` event which defines the latest state in its
|
|
174
|
+
entirety. If no token is specified using `withToken`, no message will be sent (when not using
|
|
175
|
+
authentication, it is assumed the server will send the `init` event unprompted).
|
|
176
|
+
|
|
177
|
+
- `P` (ping): Can be sent periodically to keep the connection alive. The server sends a "Pong"
|
|
178
|
+
message in response immediately.
|
|
175
179
|
|
|
176
|
-
`
|
|
177
|
-
|
|
178
|
-
|
|
180
|
+
- `{"change": <spec>, "id": <id>}`: Defines a delta. This may contain the aggregate result of many
|
|
181
|
+
operations performed on the client. The ID is an opaque identifier which is reflected back to the
|
|
182
|
+
same client in the confirmation message. Other clients will not receive the ID.
|
|
179
183
|
|
|
180
|
-
`
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
back to the same client in the confirmation message. Other clients will not
|
|
184
|
-
receive the ID.
|
|
184
|
+
- `x` (close ack): Sent by the client in response to `X` (closing). Indicates that the client will
|
|
185
|
+
not send any more messages on this connection (but may still be expecting some responses to
|
|
186
|
+
existing messages).
|
|
185
187
|
|
|
186
188
|
### Server-to-client
|
|
187
189
|
|
|
188
|
-
`p` (pong):
|
|
189
|
-
Reponse to a ping. May also be sent unsolicited.
|
|
190
|
+
- `p` (pong): Reponse to a ping. May also be sent unsolicited.
|
|
190
191
|
|
|
191
|
-
`{"init": <state>}`:
|
|
192
|
-
The first message sent by the server, in response to a successful
|
|
193
|
-
connection.
|
|
192
|
+
- `{"init": <state>}`: The first message sent by the server, in response to a successful connection.
|
|
194
193
|
|
|
195
|
-
`{"change": <spec>}`:
|
|
196
|
-
Sent whenever another client has changed the server state.
|
|
194
|
+
- `{"change": <spec>}`: Sent whenever another client has changed the server state.
|
|
197
195
|
|
|
198
|
-
`{"change": <spec>, "id": <id>}`:
|
|
199
|
-
|
|
200
|
-
the spec and ID will match the client-sent values.
|
|
196
|
+
- `{"change": <spec>, "id": <id>}`: Sent whenever the current client has changed the server state.
|
|
197
|
+
Note that the spec and ID will match the client-sent values.
|
|
201
198
|
|
|
202
|
-
The IDs sent by different clients can coincide, so the ID is only reflected
|
|
203
|
-
|
|
199
|
+
The IDs sent by different clients can coincide, so the ID is only reflected to the client which
|
|
200
|
+
sent the spec.
|
|
204
201
|
|
|
205
|
-
`{"error": <message>, "id": <id>}`:
|
|
206
|
-
Sent if the server rejects a client-initiated change.
|
|
202
|
+
- `{"error": <message>, "id": <id>}`: Sent if the server rejects a client-initiated change.
|
|
207
203
|
|
|
208
|
-
If this is returned, the server state will not have changed (i.e. the
|
|
209
|
-
|
|
204
|
+
If this is returned, the server state will not have changed (i.e. the entire spec failed).
|
|
205
|
+
|
|
206
|
+
- `X` (closing): Sent when the server is about to shut down. The client should respond with `x` and
|
|
207
|
+
not send any more messages on the current connection. Any currently in-flight messages will be
|
|
208
|
+
acknowledged on a best-effort basis by the server. The server might not wait for the acknowledging
|
|
209
|
+
`x` message before closing the connection.
|
|
210
210
|
|
|
211
211
|
### Specs
|
|
212
212
|
|
|
213
|
-
The specs need to match whichever reducer you are using. In the examples
|
|
214
|
-
above, that is
|
|
213
|
+
The specs need to match whichever reducer you are using. In the examples above, that is
|
|
215
214
|
[json-immutability-helper](https://github.com/davidje13/json-immutability-helper).
|
|
216
215
|
|
|
217
216
|
## Alternative reducer
|
|
218
217
|
|
|
219
|
-
To enable different features of `json-immutability-helper`, you can
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
such as Reverse Polish Notation (`rpn`):
|
|
223
|
-
|
|
224
|
-
### Backend
|
|
218
|
+
To enable different features of `json-immutability-helper`, you can customise it before passing it
|
|
219
|
+
to the constructor. For example, to enable list commands such as `updateWhere` and mathematical
|
|
220
|
+
commands such as Reverse Polish Notation (`rpn`):
|
|
225
221
|
|
|
226
|
-
```
|
|
222
|
+
```javascript
|
|
223
|
+
// Backend
|
|
227
224
|
import { Broadcaster, InMemoryModel } from 'shared-reducer/backend';
|
|
228
225
|
import listCommands from 'json-immutability-helper/commands/list';
|
|
229
226
|
import mathCommands from 'json-immutability-helper/commands/math';
|
|
230
227
|
import context from 'json-immutability-helper';
|
|
231
228
|
|
|
232
|
-
const broadcaster =
|
|
233
|
-
|
|
234
|
-
.
|
|
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
|
-
|
|
229
|
+
const broadcaster = new Broadcaster(
|
|
230
|
+
new InMemoryModel(),
|
|
231
|
+
context.with(listCommands, mathCommands),
|
|
232
|
+
);
|
|
243
233
|
```
|
|
244
234
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
```js
|
|
235
|
+
```javascript
|
|
236
|
+
// Frontend
|
|
248
237
|
import { SharedReducer } from 'shared-reducer/frontend';
|
|
249
238
|
import listCommands from 'json-immutability-helper/commands/list';
|
|
250
239
|
import mathCommands from 'json-immutability-helper/commands/math';
|
|
251
240
|
import context from 'json-immutability-helper';
|
|
252
241
|
|
|
253
|
-
const reducer = SharedReducer
|
|
254
|
-
.
|
|
255
|
-
|
|
256
|
-
|
|
242
|
+
const reducer = new SharedReducer(
|
|
243
|
+
context.with(listCommands, mathCommands),
|
|
244
|
+
() => ({ url: 'ws://destination' }),
|
|
245
|
+
);
|
|
257
246
|
```
|
|
258
247
|
|
|
259
|
-
If you want to use an entirely different reducer, create a wrapper
|
|
260
|
-
and pass it to `withReducer`:
|
|
248
|
+
If you want to use an entirely different reducer, create a wrapper:
|
|
261
249
|
|
|
262
|
-
```
|
|
250
|
+
```javascript
|
|
263
251
|
import context from 'json-immutability-helper';
|
|
264
252
|
|
|
265
253
|
const myReducer = {
|
|
@@ -273,31 +261,29 @@ const myReducer = {
|
|
|
273
261
|
},
|
|
274
262
|
};
|
|
275
263
|
|
|
276
|
-
//
|
|
277
|
-
const broadcaster = Broadcaster
|
|
278
|
-
.withReducer(myReducer)
|
|
279
|
-
.build();
|
|
264
|
+
// Backend
|
|
265
|
+
const broadcaster = new Broadcaster(new InMemoryModel(), myReducer);
|
|
280
266
|
|
|
281
|
-
//
|
|
282
|
-
const reducer = SharedReducer
|
|
283
|
-
.for('ws://destination', (state) => {})
|
|
284
|
-
.withReducer(myReducer)
|
|
285
|
-
.build();
|
|
267
|
+
// Frontend
|
|
268
|
+
const reducer = new SharedReducer(myReducer, () => ({ url: 'ws://destination' }));
|
|
286
269
|
```
|
|
287
270
|
|
|
288
|
-
Be careful when using your own reducer to avoid introducing
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
as code injection or prototype pollution.
|
|
271
|
+
Be careful when using your own reducer to avoid introducing security vulnerabilities; the functions
|
|
272
|
+
will be called with untrusted input, so should be careful to avoid attacks such as code injection or
|
|
273
|
+
prototype pollution.
|
|
292
274
|
|
|
293
275
|
## Other customisations (Backend)
|
|
294
276
|
|
|
295
|
-
The `Broadcaster`
|
|
277
|
+
The `Broadcaster` constructor can also take some optional arguments:
|
|
278
|
+
|
|
279
|
+
```javascript
|
|
280
|
+
new Broadcaster(model, reducer[, options]);
|
|
281
|
+
```
|
|
296
282
|
|
|
297
|
-
- `
|
|
298
|
-
|
|
283
|
+
- `options.subscribers`: specify a custom keyed broadcaster, used for communicating changes to all
|
|
284
|
+
consumers. Required interface:
|
|
299
285
|
|
|
300
|
-
```
|
|
286
|
+
```javascript
|
|
301
287
|
{
|
|
302
288
|
add(key, listener) {
|
|
303
289
|
// add the listener function to key
|
|
@@ -314,15 +300,14 @@ The `Broadcaster` builder has other settable properties:
|
|
|
314
300
|
|
|
315
301
|
All functions can be asynchronous or synchronous.
|
|
316
302
|
|
|
317
|
-
The main use-case for overriding this would be to share
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
_documents_ rather than _users_ for better scalability.
|
|
303
|
+
The main use-case for overriding this would be to share messages between multiple servers for load
|
|
304
|
+
balancing, but note that in most cases you probably want to load balance _documents_ rather than
|
|
305
|
+
_users_ for better scalability.
|
|
321
306
|
|
|
322
|
-
- `
|
|
323
|
-
|
|
307
|
+
- `options.taskQueues`: specify a custom task queue, used to ensure operations happen in the correct
|
|
308
|
+
order. Required interface:
|
|
324
309
|
|
|
325
|
-
```
|
|
310
|
+
```javascript
|
|
326
311
|
{
|
|
327
312
|
push(key, task) {
|
|
328
313
|
// add the (possibly asynchronous) task to the queue
|
|
@@ -331,33 +316,80 @@ The `Broadcaster` builder has other settable properties:
|
|
|
331
316
|
}
|
|
332
317
|
```
|
|
333
318
|
|
|
334
|
-
The default implementation will execute the task if it is
|
|
335
|
-
|
|
336
|
-
a
|
|
337
|
-
the existing tasks have finished. Once all tasks for a
|
|
338
|
-
particular key have finished, it will remove the queue.
|
|
319
|
+
The default implementation will execute the task if it is the first task in a particular queue. If
|
|
320
|
+
there is already a task in the queue, it will be stored and executed once the existing tasks have
|
|
321
|
+
finished. Once all tasks for a particular key have finished, it will remove the queue.
|
|
339
322
|
|
|
340
|
-
As with `
|
|
341
|
-
|
|
342
|
-
able to modify the same document simultaneously.
|
|
323
|
+
As with `subscribers`, the main reason to override this is to provide consistency if multiple
|
|
324
|
+
servers are able to modify the same document simultaneously.
|
|
343
325
|
|
|
344
|
-
- `
|
|
345
|
-
|
|
326
|
+
- `options.idProvider`: specify a custom unique ID provider. Must be a function which returns a
|
|
327
|
+
unique string ID when called. Can be asynchronous.
|
|
346
328
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
329
|
+
The returned ID is used internally and passed through the configured `taskQueues` to identify the
|
|
330
|
+
source of a change. It is not revealed to users. The default implementation uses a fixed random
|
|
331
|
+
prefix followed by an incrementing number, which should be sufficient for most use cases.
|
|
332
|
+
|
|
333
|
+
## Other customisations (Frontend)
|
|
334
|
+
|
|
335
|
+
If the connection is lost, the frontend will attempt to reconnect automatically. By default this
|
|
336
|
+
uses an exponential backoff with a small amount of randomness, as well as attempting to connect if
|
|
337
|
+
the page regains focus or the computer rejoins a network. You can fully customise this behaviour:
|
|
338
|
+
|
|
339
|
+
```javascript
|
|
340
|
+
import { SharedReducer, OnlineScheduler, exponentialDelay } from 'shared-reducer/frontend';
|
|
341
|
+
|
|
342
|
+
const reducer = new SharedReducer(context, () => ({ url: 'ws://destination' }), {
|
|
343
|
+
scheduler: new OnlineScheduler(
|
|
344
|
+
exponentialDelay({
|
|
345
|
+
base: 2,
|
|
346
|
+
initialDelay: 200,
|
|
347
|
+
maxDelay: 10 * 60 * 1000,
|
|
348
|
+
randomness: 0.3,
|
|
349
|
+
}),
|
|
350
|
+
20 * 1000, // timeout for each connection attempt
|
|
351
|
+
),
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
The `exponentialDelay` helper returns:
|
|
356
|
+
|
|
357
|
+
```
|
|
358
|
+
min(initialDelay * (base ^ attempt), maxDelay) * (1 - random(randomness))
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
All delay values are in milliseconds.
|
|
362
|
+
|
|
363
|
+
You can also provide a custom function instead of `exponentialDelay`; it will be given the current
|
|
364
|
+
attempt number (0-based), and should return the number of milliseconds to wait before triggering the
|
|
365
|
+
attempt.
|
|
366
|
+
|
|
367
|
+
Finally, by default when reconnecting `SharedReducer` will replay all messages which have not been
|
|
368
|
+
confirmed (`AT_LEAST_ONCE` delivery). You can change this to `AT_MOST_ONCE` or a custom mechanism:
|
|
369
|
+
|
|
370
|
+
```javascript
|
|
371
|
+
import { SharedReducer, AT_MOST_ONCE } from 'shared-reducer/frontend';
|
|
372
|
+
|
|
373
|
+
const reducer = new SharedReducer(context, () => ({ url: 'ws://destination' }), {
|
|
374
|
+
deliveryStrategy: AT_MOST_ONCE,
|
|
375
|
+
});
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Custom strategies can be defined as functions:
|
|
379
|
+
|
|
380
|
+
```javascript
|
|
381
|
+
function myCustomDeliveryStrategy(serverState, spec, hasSent) {
|
|
382
|
+
return true; // re-send all (equivalent to AT_LEAST_ONCE)
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
- `serverState` is the new state from the server after reconnecting.
|
|
387
|
+
- `spec` is a spec that has not been confirmed as delivered to the server.
|
|
388
|
+
- `hasSent` is `true` if the spec has already been sent to the server (but no delivery confirmation
|
|
389
|
+
was received). It is `false` if the message was never sent to the server.
|
|
354
390
|
|
|
355
|
-
|
|
356
|
-
|
|
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.
|
|
391
|
+
Note that the function will be invoked multiple times (once for each change that is pending). It
|
|
392
|
+
should return `true` for messages to resend, and `false` for messages to drop.
|
|
361
393
|
|
|
362
394
|
## Older versions
|
|
363
395
|
|
package/backend/index.d.ts
CHANGED
|
@@ -1,33 +1,30 @@
|
|
|
1
|
-
|
|
2
|
-
private readonly _shared;
|
|
3
|
-
private _unique;
|
|
4
|
-
get(): string;
|
|
5
|
-
}
|
|
1
|
+
type MaybePromise<T> = Promise<T> | T;
|
|
6
2
|
|
|
7
|
-
type Task<T> = () =>
|
|
8
|
-
interface TaskQueue
|
|
9
|
-
push(task: Task<T>): Promise<T>;
|
|
3
|
+
type Task<T> = () => MaybePromise<T>;
|
|
4
|
+
interface TaskQueue extends EventTarget {
|
|
5
|
+
push<T>(task: Task<T>): Promise<T>;
|
|
6
|
+
active(): boolean;
|
|
10
7
|
}
|
|
11
|
-
type TaskQueueFactory
|
|
8
|
+
type TaskQueueFactory = () => TaskQueue;
|
|
12
9
|
|
|
13
|
-
declare class TaskQueueMap<
|
|
10
|
+
declare class TaskQueueMap<K> {
|
|
14
11
|
private readonly _queueFactory;
|
|
15
12
|
private readonly _queues;
|
|
16
|
-
constructor(_queueFactory?: () => TaskQueue
|
|
17
|
-
push(key:
|
|
13
|
+
constructor(_queueFactory?: () => TaskQueue);
|
|
14
|
+
push<T>(key: K, task: Task<T>): Promise<T>;
|
|
18
15
|
}
|
|
19
16
|
|
|
20
17
|
type TopicListener<T> = (message: T) => void;
|
|
21
18
|
interface Topic<T> {
|
|
22
|
-
add(fn: TopicListener<T>):
|
|
23
|
-
remove(fn: TopicListener<T>):
|
|
24
|
-
broadcast(message: T):
|
|
19
|
+
add(fn: TopicListener<T>): MaybePromise<void>;
|
|
20
|
+
remove(fn: TopicListener<T>): MaybePromise<boolean>;
|
|
21
|
+
broadcast(message: T): MaybePromise<void>;
|
|
25
22
|
}
|
|
26
23
|
|
|
27
|
-
interface TopicMap<T> {
|
|
28
|
-
add(key:
|
|
29
|
-
remove(key:
|
|
30
|
-
broadcast(key:
|
|
24
|
+
interface TopicMap<K, T> {
|
|
25
|
+
add(key: K, fn: TopicListener<T>): MaybePromise<void>;
|
|
26
|
+
remove(key: K, fn: TopicListener<T>): MaybePromise<void>;
|
|
27
|
+
broadcast(key: K, message: T): MaybePromise<void>;
|
|
31
28
|
}
|
|
32
29
|
|
|
33
30
|
declare class PermissionError extends Error {
|
|
@@ -37,19 +34,21 @@ interface Permission<T, SpecT> {
|
|
|
37
34
|
validateWrite(newValue: T, oldValue: T): void;
|
|
38
35
|
}
|
|
39
36
|
|
|
40
|
-
interface Model<T> {
|
|
37
|
+
interface Model<ID, T> {
|
|
41
38
|
validate(v: unknown): Readonly<T>;
|
|
42
|
-
read(id:
|
|
43
|
-
write(id:
|
|
39
|
+
read(id: ID): MaybePromise<Readonly<T> | null | undefined>;
|
|
40
|
+
write(id: ID, newValue: T, oldValue: T): MaybePromise<void>;
|
|
44
41
|
}
|
|
45
42
|
|
|
46
43
|
interface Context<T, SpecT> {
|
|
47
44
|
update: (input: T, spec: SpecT) => T;
|
|
48
45
|
}
|
|
46
|
+
type Listener<SpecT, MetaT> = (message: ChangeInfo<SpecT>, meta: MetaT | undefined) => void;
|
|
49
47
|
interface Subscription<T, SpecT, MetaT> {
|
|
50
|
-
getInitialData
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
getInitialData(): Readonly<T>;
|
|
49
|
+
listen(onChange: Listener<SpecT, MetaT>): void;
|
|
50
|
+
send(change: SpecT, meta?: MetaT): Promise<void>;
|
|
51
|
+
close(): Promise<void>;
|
|
53
52
|
}
|
|
54
53
|
type Identifier = string | null;
|
|
55
54
|
type ChangeInfo<SpecT> = {
|
|
@@ -64,67 +63,88 @@ interface TopicMessage<SpecT> {
|
|
|
64
63
|
source: Identifier;
|
|
65
64
|
meta?: unknown;
|
|
66
65
|
}
|
|
67
|
-
|
|
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
|
-
}
|
|
66
|
+
type ID = string;
|
|
74
67
|
declare class Broadcaster<T, SpecT> {
|
|
75
68
|
private readonly _model;
|
|
76
69
|
private readonly _context;
|
|
77
70
|
private readonly _subscribers;
|
|
78
71
|
private readonly _taskQueues;
|
|
79
72
|
private readonly _idProvider;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
73
|
+
constructor(_model: Model<ID, T>, _context: Context<T, SpecT>, options?: {
|
|
74
|
+
subscribers?: TopicMap<ID, TopicMessage<SpecT>>;
|
|
75
|
+
taskQueues?: TaskQueueMap<ID>;
|
|
76
|
+
idProvider?: () => MaybePromise<string>;
|
|
77
|
+
});
|
|
78
|
+
subscribe<MetaT = void>(id: ID, permission?: Permission<T, SpecT>): Promise<Subscription<T, SpecT, MetaT> | null>;
|
|
79
|
+
update(id: ID, change: SpecT, permission?: Permission<T, SpecT>): Promise<void>;
|
|
84
80
|
private _internalApplyChange;
|
|
85
81
|
private _internalQueueChange;
|
|
86
82
|
}
|
|
87
83
|
|
|
88
84
|
declare const PING = "P";
|
|
89
85
|
declare const PONG = "p";
|
|
90
|
-
|
|
86
|
+
declare const CLOSE = "X";
|
|
87
|
+
declare const CLOSE_ACK = "x";
|
|
91
88
|
interface ServerWebSocket {
|
|
92
89
|
on(event: 'close', listener: () => void): void;
|
|
93
90
|
on(event: 'message', listener: (data: unknown, isBinary?: boolean) => void): void;
|
|
91
|
+
on(event: 'pong', listener: () => void): void;
|
|
92
|
+
ping(): void;
|
|
94
93
|
send(message: string): void;
|
|
94
|
+
terminate(): void;
|
|
95
95
|
}
|
|
96
96
|
interface WSResponse {
|
|
97
97
|
accept(): Promise<ServerWebSocket>;
|
|
98
|
-
sendError(httpStatus: number): void;
|
|
98
|
+
sendError(httpStatus: number, wsStatus?: number): void;
|
|
99
99
|
beginTransaction(): void;
|
|
100
100
|
endTransaction(): void;
|
|
101
101
|
}
|
|
102
|
-
|
|
102
|
+
interface WebsocketHandlerOptions {
|
|
103
|
+
pingInterval?: number;
|
|
104
|
+
pongTimeout?: number;
|
|
105
|
+
}
|
|
106
|
+
declare class WebsocketHandlerFactory<T, SpecT> {
|
|
107
|
+
private readonly broadcaster;
|
|
108
|
+
private readonly closers;
|
|
109
|
+
private readonly _pingInterval;
|
|
110
|
+
private readonly _pongTimeout;
|
|
111
|
+
private closing;
|
|
112
|
+
constructor(broadcaster: Broadcaster<T, SpecT>, options?: WebsocketHandlerOptions);
|
|
113
|
+
activeConnections(): number;
|
|
114
|
+
softClose(timeout: number): Promise<void>;
|
|
115
|
+
handler<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>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
declare const UniqueIdProvider: () => () => string;
|
|
103
119
|
|
|
104
120
|
interface Collection<T> {
|
|
105
121
|
get<K extends keyof T & string>(searchAttribute: K, searchValue: T[K]): Promise<Readonly<T> | null>;
|
|
106
122
|
update<K extends keyof T & string>(searchAttribute: K, searchValue: T[K], update: Partial<T>): Promise<void>;
|
|
107
123
|
}
|
|
108
|
-
|
|
124
|
+
type ErrorMapper = (e: unknown) => unknown;
|
|
125
|
+
declare class CollectionStorageModel<T extends object, K extends keyof T & string> implements Model<T[K], T> {
|
|
109
126
|
private readonly _collection;
|
|
110
127
|
private readonly _idCol;
|
|
111
128
|
readonly validate: (v: unknown) => T;
|
|
112
129
|
private readonly _readErrorIntercept;
|
|
113
130
|
private readonly _writeErrorIntercept;
|
|
114
|
-
constructor(_collection: Collection<T>, _idCol: keyof T & string, validate: (v: unknown) => T,
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
constructor(_collection: Collection<T>, _idCol: keyof T & string, validate: (v: unknown) => T, options?: {
|
|
132
|
+
readErrorIntercept?: ErrorMapper;
|
|
133
|
+
writeErrorIntercept?: ErrorMapper;
|
|
134
|
+
});
|
|
135
|
+
read(id: T[K]): Promise<Readonly<T> | null>;
|
|
136
|
+
write(id: T[K], newValue: T, oldValue: T): Promise<void>;
|
|
117
137
|
}
|
|
118
138
|
|
|
119
|
-
declare class InMemoryModel<T> implements Model<T> {
|
|
120
|
-
readonly read: (id:
|
|
139
|
+
declare class InMemoryModel<ID, T> implements Model<ID, T> {
|
|
140
|
+
readonly read: (id: ID) => Readonly<T> | undefined;
|
|
121
141
|
readonly validate: (v: unknown) => T;
|
|
122
142
|
private readonly _memory;
|
|
123
143
|
constructor(validator?: (x: unknown) => T);
|
|
124
|
-
set(id:
|
|
125
|
-
get(id:
|
|
126
|
-
delete(id:
|
|
127
|
-
write(id:
|
|
144
|
+
set(id: ID, value: T): void;
|
|
145
|
+
get(id: ID): Readonly<T> | undefined;
|
|
146
|
+
delete(id: ID): void;
|
|
147
|
+
write(id: ID, newValue: T, oldValue: T): void;
|
|
128
148
|
}
|
|
129
149
|
|
|
130
150
|
declare const ReadOnly: Permission<unknown, unknown>;
|
|
@@ -137,11 +157,12 @@ declare class ReadWriteStruct<T extends object> implements Permission<T, unknown
|
|
|
137
157
|
validateWrite(newValue: T, oldValue: T): void;
|
|
138
158
|
}
|
|
139
159
|
|
|
140
|
-
declare class AsyncTaskQueue
|
|
160
|
+
declare class AsyncTaskQueue extends EventTarget implements TaskQueue {
|
|
141
161
|
private readonly _queue;
|
|
142
162
|
private _running;
|
|
143
|
-
push(task: Task<T>): Promise<T>;
|
|
163
|
+
push<T>(task: Task<T>): Promise<T>;
|
|
144
164
|
private _internalConsumeQueue;
|
|
165
|
+
active(): boolean;
|
|
145
166
|
}
|
|
146
167
|
|
|
147
168
|
declare class InMemoryTopic<T> implements Topic<T> {
|
|
@@ -151,13 +172,13 @@ declare class InMemoryTopic<T> implements Topic<T> {
|
|
|
151
172
|
broadcast(message: T): void;
|
|
152
173
|
}
|
|
153
174
|
|
|
154
|
-
declare class TrackingTopicMap<T> implements TopicMap<T> {
|
|
175
|
+
declare class TrackingTopicMap<K, T> implements TopicMap<K, T> {
|
|
155
176
|
private readonly _topicFactory;
|
|
156
177
|
private readonly _data;
|
|
157
|
-
constructor(_topicFactory: (key:
|
|
158
|
-
add(key:
|
|
159
|
-
remove(key:
|
|
160
|
-
broadcast(key:
|
|
178
|
+
constructor(_topicFactory: (key: K) => Topic<T>);
|
|
179
|
+
add(key: K, fn: TopicListener<T>): Promise<void>;
|
|
180
|
+
remove(key: K, fn: TopicListener<T>): Promise<void>;
|
|
181
|
+
broadcast(key: K, message: T): Promise<void>;
|
|
161
182
|
}
|
|
162
183
|
|
|
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,
|
|
184
|
+
export { AsyncTaskQueue, Broadcaster, CLOSE, CLOSE_ACK, 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, WebsocketHandlerFactory };
|
package/backend/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";var t=require("node:crypto");
|
|
1
|
+
"use strict";var t=require("node:crypto");const e=()=>{const e=t.randomUUID().substring(0,8);let s=0;return()=>{const t=s++;return`${e}-${t}`}};class s extends EventTarget{t=[];i=!1;push(t){return new Promise(((e,s)=>{this.t.push((async()=>{try{e(await t())}catch(t){s(t)}})),this.i||this.o()}))}async o(){for(this.i=!0;this.t.length>0;)await this.t.shift()();this.i=!1,this.dispatchEvent(new CustomEvent("drain"))}active(){return this.i}}class r{h;l=new Map;constructor(t=()=>new s){this.h=t}push(t,e){let s=this.l.get(t);if(!s){const e=this.h(),r=()=>{e.active()||(this.l.delete(t),e.removeEventListener("drain",r))};e.addEventListener("drain",r),this.l.set(t,e),s=e}return s.push(e)}}class i{u;p=new Map;constructor(t){this.u=t}async add(t,e){let s=this.p.get(t);s||(s=this.u(t),this.p.set(t,s)),await s.add(e)}async remove(t,e){const s=this.p.get(t);if(s){await s.remove(e)||this.p.delete(t)}}async broadcast(t,e){const s=this.p.get(t);s&&await s.broadcast(e)}}class n{m=new Set;add(t){this.m.add(t)}remove(t){return this.m.delete(t),this.m.size>0}broadcast(t){this.m.forEach((e=>e(t)))}}const o={validateWrite(){}};const a=t=>t;class c extends Error{}const h="Cannot modify data",l={validateWriteSpec(){throw new c(h)},validateWrite(){throw new c(h)}};exports.AsyncTaskQueue=s,exports.Broadcaster=class{v;_;m;S;C;constructor(t,s,o={}){this.v=t,this._=s,this.m=o.subscribers??new i((()=>new n)),this.S=o.taskQueues??new r,this.C=o.idProvider??e()}async subscribe(t,e=o){let s={T:0},r="";const i=t=>{2===s.T?t.source===r?s.O(t.message,t.meta):t.message.change&&s.O(t.message,void 0):1===s.T&&s.t.push(t)};try{if(await this.S.push(t,(async()=>{const e=await this.v.read(t);null!=e&&(s={T:1,P:e,t:[]},await this.m.add(t,i))})),0===s.T)return null;r=await this.C()}catch(e){throw await this.m.remove(t,i),e}return{getInitialData(){if(1!==s.T)throw new Error("Already started");return s.P},listen(t){if(1!==s.T)throw new Error("Already started");const e=s.t;s={T:2,O:t},e.forEach(i)},send:(s,i)=>this.j(t,s,e,r,i),close:async()=>{await this.m.remove(t,i)}}}update(t,e,s=o){return this.j(t,e,s,null,void 0)}async I(t,e,s,r,i){try{const r=await this.v.read(t);if(!r)throw new Error("Deleted");s.validateWriteSpec?.(e);const i=this._.update(r,e),n=this.v.validate(i);s.validateWrite(n,r),await this.v.write(t,n,r)}catch(e){return void this.m.broadcast(t,{message:{error:e instanceof Error?e.message:"Internal error"},source:r,meta:i})}this.m.broadcast(t,{message:{change:e},source:r,meta:i})}async j(t,e,s,r,i){return this.S.push(t,(()=>this.I(t,e,s,r,i)))}},exports.CLOSE="X",exports.CLOSE_ACK="x",exports.CollectionStorageModel=class{W;k;validate;q;$;constructor(t,e,s,r={}){this.W=t,this.k=e,this.validate=s,this.q=r.readErrorIntercept??a,this.$=r.writeErrorIntercept??a}async read(t){try{return await this.W.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.W.update(this.k,t,r)}catch(t){throw this.$(t)}}},exports.InMemoryModel=class{read=this.get;validate;A=new Map;constructor(t=t=>t){this.validate=t}set(t,e){this.A.set(t,e)}get(t){return this.A.get(t)}delete(t){this.A.delete(t)}write(t,e,s){if(s!==this.A.get(t))throw new Error("Unexpected previous value");this.A.set(t,e)}},exports.InMemoryTopic=n,exports.PING="P",exports.PONG="p",exports.PermissionError=c,exports.ReadOnly=l,exports.ReadWrite=o,exports.ReadWriteStruct=class{J;constructor(t=[]){this.J=t}validateWrite(t,e){for(const s of this.J){const r=Object.prototype.hasOwnProperty.call(e,s),i=Object.prototype.hasOwnProperty.call(t,s);if(r!==i)throw new c(r?`Cannot remove field ${String(s)}`:`Cannot add field ${String(s)}`);if(i&&e[s]!==t[s])throw new c(`Cannot edit field ${String(s)}`)}}},exports.TaskQueueMap=r,exports.TrackingTopicMap=i,exports.UniqueIdProvider=e,exports.WebsocketHandlerFactory=class{broadcaster;closers=new Set;M;N;closing=!1;constructor(t,e={}){this.broadcaster=t,this.M=e.pingInterval??25e3,this.N=e.pongTimeout??3e4}activeConnections(){return this.closers.size}async softClose(t){this.closing=!0;let e=null;await Promise.race([Promise.all([...this.closers].map((t=>t()))),new Promise((s=>{e=setTimeout(s,t)}))]),null!==e&&clearTimeout(e)}handler(t,e){const s=async(s,r)=>{const i=await t(s,r),n=await e(s,r),o=await this.broadcaster.subscribe(i,n);return o||(r.sendError(404),null)};return async(t,e)=>{if(this.closing)return void e.sendError(503,1012);const r=await s(t,e).catch((t=>(console.warn("WebSocket init error",t),e.sendError(500),null)));if(r)try{const t=await e.accept();let s=0,i=()=>null;const n=()=>(this.closers.delete(n),t.send("X"),s=1,new Promise((t=>{i=t})));t.on("close",(()=>{s=2,clearTimeout(c),r.close().catch((()=>null)),this.closers.delete(n),i()}));const o=()=>{r.close().catch((()=>null)),this.closers.delete(n),t.terminate(),i()},a=()=>{t.ping(),clearTimeout(c),c=setTimeout(o,this.N)};if(t.on("pong",(()=>{clearTimeout(c),c=setTimeout(a,this.M)})),t.on("message",(async(n,o)=>{clearTimeout(c),c=setTimeout(a,this.M);try{if(o)throw new Error("Binary messages are not supported");const a=String(n);if("P"===a)return void t.send("p");if("x"===a){if(1!==s)throw new Error("Unexpected close ack message");return s=2,void i()}if(2===s)throw new Error("Unexpected message after close ack");const c=function(t){const e=JSON.parse(t);if("object"!=typeof e||!e||Array.isArray(e))throw new Error("Must specify change and optional id");const{id:s,change:r}=e;if(void 0===s)return{change:r};if("number"!=typeof s)throw new Error("if specified, id must be a number");return{change:r,id:s}}(a);e.beginTransaction();try{await r.send(c.change,c.id)}finally{e.endTransaction()}}catch(e){t.send(JSON.stringify({error:e instanceof Error?e.message:"Internal error"}))}})),this.closing)return e.sendError(503,1012),void r.close().catch((()=>null));t.send(JSON.stringify({init:r.getInitialData()})),r.listen(((e,s)=>{const r=void 0!==s?{id:s,...e}:e;t.send(JSON.stringify(r))}));let c=setTimeout(a,this.M);this.closers.add(n)}catch(t){console.warn("WebSocket error",t),e.sendError(500),r.close().catch((()=>null))}}}};
|
package/backend/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{randomUUID as t}from"node:crypto";
|
|
1
|
+
import{randomUUID as t}from"node:crypto";const e=()=>{const e=t().substring(0,8);let s=0;return()=>{const t=s++;return`${e}-${t}`}};class s extends EventTarget{t=[];i=!1;push(t){return new Promise(((e,s)=>{this.t.push((async()=>{try{e(await t())}catch(t){s(t)}})),this.i||this.o()}))}async o(){for(this.i=!0;this.t.length>0;)await this.t.shift()();this.i=!1,this.dispatchEvent(new CustomEvent("drain"))}active(){return this.i}}class r{h;l=new Map;constructor(t=()=>new s){this.h=t}push(t,e){let s=this.l.get(t);if(!s){const e=this.h(),r=()=>{e.active()||(this.l.delete(t),e.removeEventListener("drain",r))};e.addEventListener("drain",r),this.l.set(t,e),s=e}return s.push(e)}}class i{u;m=new Map;constructor(t){this.u=t}async add(t,e){let s=this.m.get(t);s||(s=this.u(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 n{p=new Set;add(t){this.p.add(t)}remove(t){return this.p.delete(t),this.p.size>0}broadcast(t){this.p.forEach((e=>e(t)))}}const a={validateWrite(){}};class o{_;v;p;S;C;constructor(t,s,a={}){this._=t,this.v=s,this.p=a.subscribers??new i((()=>new n)),this.S=a.taskQueues??new r,this.C=a.idProvider??e()}async subscribe(t,e=a){let s={T:0},r="";const i=t=>{2===s.T?t.source===r?s.O(t.message,t.meta):t.message.change&&s.O(t.message,void 0):1===s.T&&s.t.push(t)};try{if(await this.S.push(t,(async()=>{const e=await this._.read(t);null!=e&&(s={T:1,P:e,t:[]},await this.p.add(t,i))})),0===s.T)return null;r=await this.C()}catch(e){throw await this.p.remove(t,i),e}return{getInitialData(){if(1!==s.T)throw new Error("Already started");return s.P},listen(t){if(1!==s.T)throw new Error("Already started");const e=s.t;s={T:2,O:t},e.forEach(i)},send:(s,i)=>this.j(t,s,e,r,i),close:async()=>{await this.p.remove(t,i)}}}update(t,e,s=a){return this.j(t,e,s,null,void 0)}async I(t,e,s,r,i){try{const r=await this._.read(t);if(!r)throw new Error("Deleted");s.validateWriteSpec?.(e);const i=this.v.update(r,e),n=this._.validate(i);s.validateWrite(n,r),await this._.write(t,n,r)}catch(e){return void this.p.broadcast(t,{message:{error:e instanceof Error?e.message:"Internal error"},source:r,meta:i})}this.p.broadcast(t,{message:{change:e},source:r,meta:i})}async j(t,e,s,r,i){return this.S.push(t,(()=>this.I(t,e,s,r,i)))}}const c="P",h="p",l="X",u="x";class w{broadcaster;closers=new Set;W;k;closing=!1;constructor(t,e={}){this.broadcaster=t,this.W=e.pingInterval??25e3,this.k=e.pongTimeout??3e4}activeConnections(){return this.closers.size}async softClose(t){this.closing=!0;let e=null;await Promise.race([Promise.all([...this.closers].map((t=>t()))),new Promise((s=>{e=setTimeout(s,t)}))]),null!==e&&clearTimeout(e)}handler(t,e){const s=async(s,r)=>{const i=await t(s,r),n=await e(s,r),a=await this.broadcaster.subscribe(i,n);return a||(r.sendError(404),null)};return async(t,e)=>{if(this.closing)return void e.sendError(503,1012);const r=await s(t,e).catch((t=>(console.warn("WebSocket init error",t),e.sendError(500),null)));if(r)try{const t=await e.accept();let s=0,i=()=>null;const n=()=>(this.closers.delete(n),t.send("X"),s=1,new Promise((t=>{i=t})));t.on("close",(()=>{s=2,clearTimeout(c),r.close().catch((()=>null)),this.closers.delete(n),i()}));const a=()=>{r.close().catch((()=>null)),this.closers.delete(n),t.terminate(),i()},o=()=>{t.ping(),clearTimeout(c),c=setTimeout(a,this.k)};if(t.on("pong",(()=>{clearTimeout(c),c=setTimeout(o,this.W)})),t.on("message",(async(n,a)=>{clearTimeout(c),c=setTimeout(o,this.W);try{if(a)throw new Error("Binary messages are not supported");const o=String(n);if("P"===o)return void t.send("p");if("x"===o){if(1!==s)throw new Error("Unexpected close ack message");return s=2,void i()}if(2===s)throw new Error("Unexpected message after close ack");const c=function(t){const e=JSON.parse(t);if("object"!=typeof e||!e||Array.isArray(e))throw new Error("Must specify change and optional id");const{id:s,change:r}=e;if(void 0===s)return{change:r};if("number"!=typeof s)throw new Error("if specified, id must be a number");return{change:r,id:s}}(o);e.beginTransaction();try{await r.send(c.change,c.id)}finally{e.endTransaction()}}catch(e){t.send(JSON.stringify({error:e instanceof Error?e.message:"Internal error"}))}})),this.closing)return e.sendError(503,1012),void r.close().catch((()=>null));t.send(JSON.stringify({init:r.getInitialData()})),r.listen(((e,s)=>{const r=void 0!==s?{id:s,...e}:e;t.send(JSON.stringify(r))}));let c=setTimeout(o,this.W);this.closers.add(n)}catch(t){console.warn("WebSocket error",t),e.sendError(500),r.close().catch((()=>null))}}}}const d=t=>t;class f{$;q;validate;A;J;constructor(t,e,s,r={}){this.$=t,this.q=e,this.validate=s,this.A=r.readErrorIntercept??d,this.J=r.writeErrorIntercept??d}async read(t){try{return await this.$.get(this.q,t)}catch(t){throw this.A(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.q,t,r)}catch(t){throw this.J(t)}}}class m extends Error{}class y{read=this.get;validate;M=new Map;constructor(t=t=>t){this.validate=t}set(t,e){this.M.set(t,e)}get(t){return this.M.get(t)}delete(t){this.M.delete(t)}write(t,e,s){if(s!==this.M.get(t))throw new Error("Unexpected previous value");this.M.set(t,e)}}const p="Cannot modify data",g={validateWriteSpec(){throw new m(p)},validateWrite(){throw new m(p)}};class _{N;constructor(t=[]){this.N=t}validateWrite(t,e){for(const s of this.N){const r=Object.prototype.hasOwnProperty.call(e,s),i=Object.prototype.hasOwnProperty.call(t,s);if(r!==i)throw new m(r?`Cannot remove field ${String(s)}`:`Cannot add field ${String(s)}`);if(i&&e[s]!==t[s])throw new m(`Cannot edit field ${String(s)}`)}}}export{s as AsyncTaskQueue,o as Broadcaster,l as CLOSE,u as CLOSE_ACK,f as CollectionStorageModel,y as InMemoryModel,n as InMemoryTopic,c as PING,h as PONG,m as PermissionError,g as ReadOnly,a as ReadWrite,_ as ReadWriteStruct,r as TaskQueueMap,i as TrackingTopicMap,e as UniqueIdProvider,w as WebsocketHandlerFactory};
|
package/frontend/index.d.ts
CHANGED
|
@@ -2,55 +2,107 @@ interface Context<T, SpecT> {
|
|
|
2
2
|
update: (input: T, spec: SpecT) => T;
|
|
3
3
|
combine: (specs: SpecT[]) => SpecT;
|
|
4
4
|
}
|
|
5
|
-
|
|
6
|
-
|
|
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;
|
|
5
|
+
type SpecGenerator<T, SpecT> = (state: T) => SpecSource<T, SpecT>[];
|
|
6
|
+
type SpecSource<T, SpecT> = SpecT | SpecGenerator<T, SpecT> | null;
|
|
12
7
|
type DispatchSpec<T, SpecT> = SpecSource<T, SpecT>[];
|
|
13
|
-
|
|
8
|
+
interface Dispatch<T, SpecT> {
|
|
9
|
+
sync(specs?: DispatchSpec<T, SpecT>): Promise<T>;
|
|
10
|
+
(specs: DispatchSpec<T, SpecT>, syncedCallback?: (state: T) => void, errorCallback?: (error: string) => void): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Scheduler {
|
|
14
|
+
trigger(fn: (signal: AbortSignal) => Promise<void>): void;
|
|
15
|
+
schedule(fn: (signal: AbortSignal) => Promise<void>): void;
|
|
16
|
+
stop(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type MaybePromise<T> = Promise<T> | T;
|
|
20
|
+
|
|
21
|
+
type Handler = (signal: AbortSignal) => MaybePromise<void>;
|
|
22
|
+
type DelayGetter = (attempt: number) => number;
|
|
23
|
+
declare class OnlineScheduler implements Scheduler {
|
|
24
|
+
private readonly _delayGetter;
|
|
25
|
+
private readonly _connectTimeLimit;
|
|
26
|
+
private _timeout;
|
|
27
|
+
private _stop;
|
|
28
|
+
private _handler;
|
|
29
|
+
private _attempts;
|
|
30
|
+
constructor(_delayGetter: DelayGetter, _connectTimeLimit: number);
|
|
31
|
+
trigger(handler: Handler): void;
|
|
32
|
+
schedule(handler: Handler): void;
|
|
33
|
+
stop(): void;
|
|
34
|
+
private _attempt;
|
|
35
|
+
private _remove;
|
|
36
|
+
}
|
|
37
|
+
interface ExponentialDelayConfig {
|
|
38
|
+
base?: number;
|
|
39
|
+
initialDelay: number;
|
|
40
|
+
maxDelay: number;
|
|
41
|
+
randomness?: number;
|
|
42
|
+
}
|
|
43
|
+
declare const exponentialDelay: ({ base, initialDelay, maxDelay, randomness }: ExponentialDelayConfig) => DelayGetter;
|
|
14
44
|
|
|
15
|
-
|
|
45
|
+
type EventHandler<E extends Event> = ((e: E) => void) | {
|
|
46
|
+
handleEvent(e: E): void;
|
|
47
|
+
};
|
|
48
|
+
declare class TypedEventTarget<Events extends Record<string, Event>> extends EventTarget {
|
|
49
|
+
addEventListener<K extends keyof Events & string>(type: K, callback: EventHandler<Events[K]> | null, options?: AddEventListenerOptions | boolean): void;
|
|
50
|
+
removeEventListener<K extends keyof Events & string>(type: K, callback: EventHandler<Events[K]> | null, options?: EventListenerOptions | boolean): void;
|
|
51
|
+
dispatchEvent<K extends keyof Events & string>(event: TypedEvent<K, Events[K]>): boolean;
|
|
52
|
+
}
|
|
53
|
+
type TypedEvent<Type extends string, T extends Event> = T & {
|
|
54
|
+
readonly type: Type;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
interface ConnectionInfo {
|
|
58
|
+
url: string;
|
|
59
|
+
token?: string | undefined;
|
|
60
|
+
}
|
|
61
|
+
type ConnectionGetter = (signal: AbortSignal) => MaybePromise<ConnectionInfo>;
|
|
62
|
+
interface DisconnectDetail {
|
|
63
|
+
code: number;
|
|
64
|
+
reason: string;
|
|
65
|
+
}
|
|
16
66
|
|
|
17
|
-
|
|
67
|
+
type DeliveryStrategy<T, SpecT> = (serverState: T, spec: SpecT, hasSent: boolean) => boolean;
|
|
68
|
+
declare const AT_LEAST_ONCE: DeliveryStrategy<unknown, unknown>;
|
|
69
|
+
declare const AT_MOST_ONCE: DeliveryStrategy<unknown, unknown>;
|
|
18
70
|
|
|
19
|
-
interface
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
withErrorHandler(handler: (error: string) => void): this;
|
|
23
|
-
withWarningHandler(handler: (error: string) => void): this;
|
|
24
|
-
build(): SharedReducer<T, SpecT>;
|
|
71
|
+
interface SharedReducerOptions<T, SpecT> {
|
|
72
|
+
scheduler?: Scheduler | undefined;
|
|
73
|
+
deliveryStrategy?: DeliveryStrategy<T, SpecT> | undefined;
|
|
25
74
|
}
|
|
26
|
-
|
|
75
|
+
type SharedReducerEvents = {
|
|
76
|
+
connected: CustomEvent<void>;
|
|
77
|
+
disconnected: CustomEvent<DisconnectDetail>;
|
|
78
|
+
warning: CustomEvent<Error>;
|
|
79
|
+
};
|
|
80
|
+
declare class SharedReducer<T, SpecT> extends TypedEventTarget<SharedReducerEvents> {
|
|
27
81
|
private readonly _context;
|
|
28
|
-
private readonly
|
|
29
|
-
private
|
|
30
|
-
private
|
|
31
|
-
private
|
|
32
|
-
private
|
|
33
|
-
private _currentSyncCallbacks;
|
|
34
|
-
private _localChanges;
|
|
35
|
-
private _pendingChanges;
|
|
82
|
+
private readonly _ws;
|
|
83
|
+
private _paused;
|
|
84
|
+
private _state;
|
|
85
|
+
private readonly _tracker;
|
|
86
|
+
private readonly _listeners;
|
|
36
87
|
private readonly _dispatchLock;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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;
|
|
88
|
+
constructor(_context: Context<T, SpecT>, connectionGetter: ConnectionGetter, { scheduler, deliveryStrategy, }?: SharedReducerOptions<T, SpecT>);
|
|
89
|
+
readonly dispatch: Dispatch<T, SpecT>;
|
|
90
|
+
private _apply;
|
|
91
|
+
private readonly _share;
|
|
50
92
|
private _handleInitMessage;
|
|
51
93
|
private _handleChangeMessage;
|
|
52
|
-
private
|
|
53
|
-
private
|
|
94
|
+
private _handleErrorMessage;
|
|
95
|
+
private _handleGracefulClose;
|
|
96
|
+
private readonly _handleMessage;
|
|
97
|
+
addStateListener(listener: (state: Readonly<T>) => void): void;
|
|
98
|
+
removeStateListener(listener: (state: Readonly<T>) => void): void;
|
|
99
|
+
private _setLocalState;
|
|
100
|
+
getState(): Readonly<T> | undefined;
|
|
101
|
+
private _warn;
|
|
102
|
+
private readonly _handleConnected;
|
|
103
|
+
private readonly _handleConnectionFailure;
|
|
104
|
+
private readonly _handleDisconnected;
|
|
105
|
+
close(): void;
|
|
54
106
|
}
|
|
55
107
|
|
|
56
|
-
export { type Context, type Dispatch, type DispatchSpec, SharedReducer,
|
|
108
|
+
export { AT_LEAST_ONCE, AT_MOST_ONCE, type Context, type DeliveryStrategy, type Dispatch, type DispatchSpec, OnlineScheduler, type Scheduler, SharedReducer, type SharedReducerOptions, exponentialDelay };
|
package/frontend/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";class t{
|
|
1
|
+
"use strict";class t{t;i;h=null;o=null;l=null;u=0;constructor(t,s){this.t=t,this.i=s,this._=this._.bind(this)}trigger(t){this.stop(),this.l=t,this._()}schedule(t){this.l!==t&&(this.o&&this.stop(),this.l=t,null===this.h&&("hidden"===global.document?.visibilityState?global.addEventListener?.("visibilitychange",this._):(global.addEventListener?.("online",this._),this.h=setTimeout(this._,this.t(this.u))),global.addEventListener?.("pageshow",this._),global.addEventListener?.("focus",this._),++this.u))}stop(){this.l=null,this.o?.(),this.o=null,this.m()}async _(){if(this.o||!this.l)return;this.m();const t=new AbortController;let s=()=>null;this.o=()=>{s(),t.abort()};try{await Promise.race([this.l(t.signal),new Promise(((t,e)=>{const i=setTimeout(e,this.i);s=()=>clearTimeout(i)}))]),this.l=null,this.u=0}catch(s){if(!t.signal.aborted){t.abort();const s=this.l;s&&(this.l=null,this.schedule(s))}}finally{s(),this.o=null}}m(){null!==this.h&&(clearTimeout(this.h),this.h=null,global.removeEventListener?.("online",this._),global.removeEventListener?.("pageshow",this._),global.removeEventListener?.("visibilitychange",this._),global.removeEventListener?.("focus",this._))}}const s=({base:t=2,initialDelay:s,maxDelay:e,randomness:i=0})=>n=>Math.min(Math.pow(t,n)*s,e)*(1-Math.random()*i);function e(t,s,e){const i=[],n=[];function h(){if(n.length>0){const e=t.combine(n);i.push(e),s=t.update(s,e),n.length=0}}return function(t,s){let e={p:t,v:0,C:null};for(;e;)if(e.v>=e.p.length)e=e.C;else{const t=s(e.p[e.v]);++e.v,t&&t.length&&(e={p:t,v:0,C:e})}}(e,(t=>{if("function"==typeof t){h();return t(s)}return t&&n.push(t),null})),h(),{S:s,T:t.combine(i)}}class i extends EventTarget{addEventListener(t,s,e){super.addEventListener(t,s,e)}removeEventListener(t,s,e){super.removeEventListener(t,s,e)}dispatchEvent(t){return super.dispatchEvent(t)}}const n=(t,s)=>new CustomEvent(t,{detail:s});class h extends i{$;k;I=null;P=!1;constructor(t,s){super(),this.$=t,this.k=s,this.L=this.L.bind(this),this.k.trigger(this.L)}async L(t){const{url:s,token:e}=await this.$(t);t.throwIfAborted();const i=new AbortController,h=i.signal;await new Promise(((u,d)=>{const _=new WebSocket(s);let g=!0;const f=t=>{i.abort(),_.close(),g?(g=!1,this.dispatchEvent(n("connectionfailure",t)),d(new Error(`handshake failed: ${t.code} ${t.reason}`))):(this.I=null,this.dispatchEvent(n("disconnected",t)),this.P||this.k.schedule(this.L))};e&&_.addEventListener("open",(()=>_.send(e)),{once:!0,signal:h}),_.addEventListener("message",(t=>{t.data!==c&&(g&&(g=!1,this.I=_,this.dispatchEvent(n("connected")),u()),this.dispatchEvent(n("message",t.data)))}),{signal:h}),_.addEventListener("close",f,{signal:h}),_.addEventListener("error",(()=>f(o)),{signal:h}),t.addEventListener("abort",(()=>f(r)),{signal:h}),function(t){const s=new AbortController;let e=null;const i=()=>{null!==e&&(clearTimeout(e),e=null),t.send(l)},n=()=>{null!==e&&clearTimeout(e),e=setTimeout(i,a)},h=()=>{null!==e&&(clearTimeout(e),e=null),s.abort()};t.addEventListener("open",n,{once:!0,signal:s.signal}),t.addEventListener("message",n,{signal:s.signal}),t.addEventListener("close",h,{signal:s.signal}),t.addEventListener("error",h,{signal:s.signal}),global.addEventListener?.("offline",i,{signal:s.signal})}(_)})).catch((t=>{throw i.abort(),t}))}isConnected(){return null!==this.I}send=t=>{if(!this.I)throw new Error("connection lost");this.I.send(t)};close(){this.P=!0,this.k.stop(),this.I?.close()}}const o={code:0,reason:"client side error"},r={code:0,reason:"handshake timeout"},l="P",c="p",a=2e4,u=()=>!0;class d{M;A;O=[];j=function(){let t=1;return()=>t++}();constructor(t,s){this.M=t,this.A=s}D(t){this.O.push({J:void 0,N:t,F:[],G:[]})}q(t,s,e){if(s||e)if(this.O.length){const t=this.O[this.O.length-1];t.F.push(s??_),t.G.push(e??_)}else s&&Promise.resolve(t).then(s)}U(t){let s=0;for(let e=0;e<this.O.length;++e){const i=this.O[e];this.A(t,i.N,void 0!==i.J)?(i.J=void 0,this.O[e-s]=i):(i.G.forEach((t=>t("message possibly lost"))),++s)}this.O.length-=s}W(t){for(let t=0,s=0;t<=this.O.length;++t){const e=this.O[t];if(!e||void 0!==e.J||e.F.length>0||e.G.length>0){const e=t-s;if(e>1){const i=this.O[t-1],n=this.O.splice(s,e,i);i.N=this.M.combine(n.map((t=>t.N))),t-=e-1}s=t+1}}for(const s of this.O)void 0===s.J&&(s.J=this.j(),t(JSON.stringify({change:s.N,id:s.J})))}X(t){const s=void 0===t?-1:this.O.findIndex((s=>s.J===t));return-1===s?{B:null,H:!1}:{B:this.O.splice(s,1)[0],H:0===s}}K(t){if(!this.O.length)return t;const s=this.M.combine(this.O.map((({N:t})=>t)));return this.M.update(t,s)}}const _=()=>null;const g={code:0,reason:"graceful shutdown"},f=s({base:2,initialDelay:1e3,maxDelay:6e5,randomness:.3});exports.AT_LEAST_ONCE=u,exports.AT_MOST_ONCE=(t,s,e)=>!e,exports.OnlineScheduler=t,exports.SharedReducer=class extends i{M;I;R=!0;S={V:0,Y:[]};Z;tt=new Set;st=function(t){let s=!1;return e=>{if(s)throw new Error(t);try{return s=!0,e()}finally{s=!1}}}("Cannot dispatch recursively");constructor(s,e,{scheduler:i=new t(f,2e4),deliveryStrategy:n=u}={}){super(),this.M=s,this.Z=new d(s,n),this.I=new h(e,i),this.I.addEventListener("message",this.et),this.I.addEventListener("connected",this.it),this.I.addEventListener("connectionfailure",this.nt),this.I.addEventListener("disconnected",this.ht)}dispatch=function(t){return Object.assign(t,{sync:(s=[])=>new Promise(((e,i)=>t(s,e,(t=>i(new Error(t))))))})}(((t,s,e)=>{if(!t.length&&!s&&!e)return;const i={ot:t,F:s,G:e};switch(this.S.V){case-1:throw new Error("closed");case 0:this.S.Y.push(i);break;case 1:this.rt(this.lt(this.S.ct,[i])),this.dt.ut()}}));lt(t,s){return this.st((()=>{for(const{ot:i,F:n,G:h}of s){if(i.length){const{S:s,T:n}=e(this.M,t,i);t=s,this.Z.D(n)}this.Z.q(t,n,h)}return t}))}dt=function(t){let s=null;const e=()=>{null!==s&&(clearTimeout(s),s=null)},i=()=>{e(),t()};return{_t:i,ut:()=>{null===s&&(s=setTimeout(i,0))},o:e}}((()=>{this.I.isConnected()&&!this.R&&this.Z.W(this.I.send)}));gt(t){if(-1!==this.S.V){if(this.R=!1,0===this.S.V){const s=this.lt(t.init,this.S.Y);this.S={V:1,ft:t.init,ct:s},this.rt(s,!0)}else this.S.ft=t.init,this.Z.U(t.init),this.rt(this.Z.K(t.init));this.dt._t()}else this.wt(`Ignoring init after closing: ${JSON.stringify(t)}`)}vt(t){if(1!==this.S.V)return void this.wt(`Ignoring change before init: ${JSON.stringify(t)}`);const s=this.S.ft=this.M.update(this.S.ft,t.change),{B:e,H:i}=this.Z.X(t.id);i||this.rt(this.Z.K(s)),e?.F.forEach((t=>t(s)))}bt(t){if(1!==this.S.V)return void this.wt(`Ignoring error before init: ${JSON.stringify(t)}`);const{B:s}=this.Z.X(t.id);s?(this.wt(`API rejected update: ${t.error}`),s?.G.forEach((s=>s(t.error))),this.rt(this.Z.K(this.S.ft))):this.wt(`API sent error: ${t.error}`)}yt(){this.I.send("x"),this.R?this.wt("Unexpected extra close message"):(this.R=!0,this.dispatchEvent(n("disconnected",g)))}et=t=>{if("X"===t.detail)return void this.yt();const s=JSON.parse(t.detail);"change"in s?this.vt(s):"init"in s?this.gt(s):"error"in s?this.bt(s):this.wt(`Ignoring unknown API message: ${t.detail}`)};addStateListener(t){this.tt.add(t),1===this.S.V&&t(this.S.ct)}removeStateListener(t){this.tt.delete(t)}rt(t,s=!1){if(1!==this.S.V)throw new Error("invalid state");if(s||this.S.ct!==t){this.S.ct=t;for(const s of this.tt)s(t)}}getState(){return 1===this.S.V?this.S.ct:void 0}wt(t){this.dispatchEvent(n("warning",new Error(t)))}it=()=>{this.dispatchEvent(n("connected"))};nt=t=>{this.dispatchEvent(n("warning",new Error(`connection failure: ${t.detail.code} ${t.detail.reason}`)))};ht=t=>{this.R||(this.R=!0,this.dispatchEvent(n("disconnected",t.detail)))};close(){this.R=!0,this.S={V:-1},this.I.close(),this.dt.o(),this.tt.clear(),this.I.removeEventListener("message",this.et),this.I.removeEventListener("connected",this.it),this.I.removeEventListener("connectionfailure",this.nt),this.I.removeEventListener("disconnected",this.ht)}},exports.exponentialDelay=s;
|
package/frontend/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
class t{t;i;h=null;o=null;l=null;u=0;constructor(t,s){this.t=t,this.i=s,this._=this._.bind(this)}trigger(t){this.stop(),this.l=t,this._()}schedule(t){this.l!==t&&(this.o&&this.stop(),this.l=t,null===this.h&&("hidden"===global.document?.visibilityState?global.addEventListener?.("visibilitychange",this._):(global.addEventListener?.("online",this._),this.h=setTimeout(this._,this.t(this.u))),global.addEventListener?.("pageshow",this._),global.addEventListener?.("focus",this._),++this.u))}stop(){this.l=null,this.o?.(),this.o=null,this.m()}async _(){if(this.o||!this.l)return;this.m();const t=new AbortController;let s=()=>null;this.o=()=>{s(),t.abort()};try{await Promise.race([this.l(t.signal),new Promise(((t,e)=>{const i=setTimeout(e,this.i);s=()=>clearTimeout(i)}))]),this.l=null,this.u=0}catch(s){if(!t.signal.aborted){t.abort();const s=this.l;s&&(this.l=null,this.schedule(s))}}finally{s(),this.o=null}}m(){null!==this.h&&(clearTimeout(this.h),this.h=null,global.removeEventListener?.("online",this._),global.removeEventListener?.("pageshow",this._),global.removeEventListener?.("visibilitychange",this._),global.removeEventListener?.("focus",this._))}}const s=({base:t=2,initialDelay:s,maxDelay:e,randomness:i=0})=>n=>Math.min(Math.pow(t,n)*s,e)*(1-Math.random()*i);function e(t,s,e){const i=[],n=[];function h(){if(n.length>0){const e=t.combine(n);i.push(e),s=t.update(s,e),n.length=0}}return function(t,s){let e={p:t,v:0,C:null};for(;e;)if(e.v>=e.p.length)e=e.C;else{const t=s(e.p[e.v]);++e.v,t&&t.length&&(e={p:t,v:0,C:e})}}(e,(t=>{if("function"==typeof t){h();return t(s)}return t&&n.push(t),null})),h(),{S:s,T:t.combine(i)}}class i extends EventTarget{addEventListener(t,s,e){super.addEventListener(t,s,e)}removeEventListener(t,s,e){super.removeEventListener(t,s,e)}dispatchEvent(t){return super.dispatchEvent(t)}}const n=(t,s)=>new CustomEvent(t,{detail:s});class h extends i{$;k;I=null;P=!1;constructor(t,s){super(),this.$=t,this.k=s,this.L=this.L.bind(this),this.k.trigger(this.L)}async L(t){const{url:s,token:e}=await this.$(t);t.throwIfAborted();const i=new AbortController,h=i.signal;await new Promise(((u,d)=>{const _=new WebSocket(s);let g=!0;const f=t=>{i.abort(),_.close(),g?(g=!1,this.dispatchEvent(n("connectionfailure",t)),d(new Error(`handshake failed: ${t.code} ${t.reason}`))):(this.I=null,this.dispatchEvent(n("disconnected",t)),this.P||this.k.schedule(this.L))};e&&_.addEventListener("open",(()=>_.send(e)),{once:!0,signal:h}),_.addEventListener("message",(t=>{t.data!==c&&(g&&(g=!1,this.I=_,this.dispatchEvent(n("connected")),u()),this.dispatchEvent(n("message",t.data)))}),{signal:h}),_.addEventListener("close",f,{signal:h}),_.addEventListener("error",(()=>f(o)),{signal:h}),t.addEventListener("abort",(()=>f(r)),{signal:h}),function(t){const s=new AbortController;let e=null;const i=()=>{null!==e&&(clearTimeout(e),e=null),t.send(l)},n=()=>{null!==e&&clearTimeout(e),e=setTimeout(i,a)},h=()=>{null!==e&&(clearTimeout(e),e=null),s.abort()};t.addEventListener("open",n,{once:!0,signal:s.signal}),t.addEventListener("message",n,{signal:s.signal}),t.addEventListener("close",h,{signal:s.signal}),t.addEventListener("error",h,{signal:s.signal}),global.addEventListener?.("offline",i,{signal:s.signal})}(_)})).catch((t=>{throw i.abort(),t}))}isConnected(){return null!==this.I}send=t=>{if(!this.I)throw new Error("connection lost");this.I.send(t)};close(){this.P=!0,this.k.stop(),this.I?.close()}}const o={code:0,reason:"client side error"},r={code:0,reason:"handshake timeout"},l="P",c="p",a=2e4,u=()=>!0,d=(t,s,e)=>!e;class _{M;A;O=[];j=function(){let t=1;return()=>t++}();constructor(t,s){this.M=t,this.A=s}D(t){this.O.push({J:void 0,N:t,F:[],G:[]})}q(t,s,e){if(s||e)if(this.O.length){const t=this.O[this.O.length-1];t.F.push(s??g),t.G.push(e??g)}else s&&Promise.resolve(t).then(s)}U(t){let s=0;for(let e=0;e<this.O.length;++e){const i=this.O[e];this.A(t,i.N,void 0!==i.J)?(i.J=void 0,this.O[e-s]=i):(i.G.forEach((t=>t("message possibly lost"))),++s)}this.O.length-=s}W(t){for(let t=0,s=0;t<=this.O.length;++t){const e=this.O[t];if(!e||void 0!==e.J||e.F.length>0||e.G.length>0){const e=t-s;if(e>1){const i=this.O[t-1],n=this.O.splice(s,e,i);i.N=this.M.combine(n.map((t=>t.N))),t-=e-1}s=t+1}}for(const s of this.O)void 0===s.J&&(s.J=this.j(),t(JSON.stringify({change:s.N,id:s.J})))}X(t){const s=void 0===t?-1:this.O.findIndex((s=>s.J===t));return-1===s?{B:null,H:!1}:{B:this.O.splice(s,1)[0],H:0===s}}K(t){if(!this.O.length)return t;const s=this.M.combine(this.O.map((({N:t})=>t)));return this.M.update(t,s)}}const g=()=>null;class f extends i{M;I;R=!0;S={V:0,Y:[]};Z;tt=new Set;st=function(t){let s=!1;return e=>{if(s)throw new Error(t);try{return s=!0,e()}finally{s=!1}}}("Cannot dispatch recursively");constructor(s,e,{scheduler:i=new t(w,2e4),deliveryStrategy:n=u}={}){super(),this.M=s,this.Z=new _(s,n),this.I=new h(e,i),this.I.addEventListener("message",this.et),this.I.addEventListener("connected",this.it),this.I.addEventListener("connectionfailure",this.nt),this.I.addEventListener("disconnected",this.ht)}dispatch=function(t){return Object.assign(t,{sync:(s=[])=>new Promise(((e,i)=>t(s,e,(t=>i(new Error(t))))))})}(((t,s,e)=>{if(!t.length&&!s&&!e)return;const i={ot:t,F:s,G:e};switch(this.S.V){case-1:throw new Error("closed");case 0:this.S.Y.push(i);break;case 1:this.rt(this.lt(this.S.ct,[i])),this.dt.ut()}}));lt(t,s){return this.st((()=>{for(const{ot:i,F:n,G:h}of s){if(i.length){const{S:s,T:n}=e(this.M,t,i);t=s,this.Z.D(n)}this.Z.q(t,n,h)}return t}))}dt=function(t){let s=null;const e=()=>{null!==s&&(clearTimeout(s),s=null)},i=()=>{e(),t()};return{_t:i,ut:()=>{null===s&&(s=setTimeout(i,0))},o:e}}((()=>{this.I.isConnected()&&!this.R&&this.Z.W(this.I.send)}));gt(t){if(-1!==this.S.V){if(this.R=!1,0===this.S.V){const s=this.lt(t.init,this.S.Y);this.S={V:1,ft:t.init,ct:s},this.rt(s,!0)}else this.S.ft=t.init,this.Z.U(t.init),this.rt(this.Z.K(t.init));this.dt._t()}else this.wt(`Ignoring init after closing: ${JSON.stringify(t)}`)}vt(t){if(1!==this.S.V)return void this.wt(`Ignoring change before init: ${JSON.stringify(t)}`);const s=this.S.ft=this.M.update(this.S.ft,t.change),{B:e,H:i}=this.Z.X(t.id);i||this.rt(this.Z.K(s)),e?.F.forEach((t=>t(s)))}bt(t){if(1!==this.S.V)return void this.wt(`Ignoring error before init: ${JSON.stringify(t)}`);const{B:s}=this.Z.X(t.id);s?(this.wt(`API rejected update: ${t.error}`),s?.G.forEach((s=>s(t.error))),this.rt(this.Z.K(this.S.ft))):this.wt(`API sent error: ${t.error}`)}yt(){this.I.send("x"),this.R?this.wt("Unexpected extra close message"):(this.R=!0,this.dispatchEvent(n("disconnected",m)))}et=t=>{if("X"===t.detail)return void this.yt();const s=JSON.parse(t.detail);"change"in s?this.vt(s):"init"in s?this.gt(s):"error"in s?this.bt(s):this.wt(`Ignoring unknown API message: ${t.detail}`)};addStateListener(t){this.tt.add(t),1===this.S.V&&t(this.S.ct)}removeStateListener(t){this.tt.delete(t)}rt(t,s=!1){if(1!==this.S.V)throw new Error("invalid state");if(s||this.S.ct!==t){this.S.ct=t;for(const s of this.tt)s(t)}}getState(){return 1===this.S.V?this.S.ct:void 0}wt(t){this.dispatchEvent(n("warning",new Error(t)))}it=()=>{this.dispatchEvent(n("connected"))};nt=t=>{this.dispatchEvent(n("warning",new Error(`connection failure: ${t.detail.code} ${t.detail.reason}`)))};ht=t=>{this.R||(this.R=!0,this.dispatchEvent(n("disconnected",t.detail)))};close(){this.R=!0,this.S={V:-1},this.I.close(),this.dt.o(),this.tt.clear(),this.I.removeEventListener("message",this.et),this.I.removeEventListener("connected",this.it),this.I.removeEventListener("connectionfailure",this.nt),this.I.removeEventListener("disconnected",this.ht)}}const m={code:0,reason:"graceful shutdown"},w=s({base:2,initialDelay:1e3,maxDelay:6e5,randomness:.3});export{u as AT_LEAST_ONCE,d as AT_MOST_ONCE,t as OnlineScheduler,f as SharedReducer,s as exponentialDelay};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shared-reducer",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.1",
|
|
4
4
|
"description": "shared state management",
|
|
5
5
|
"author": "David Evans",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,14 +47,14 @@
|
|
|
47
47
|
"@rollup/plugin-terser": "0.4.x",
|
|
48
48
|
"@rollup/plugin-typescript": "11.x",
|
|
49
49
|
"collection-storage": "3.x",
|
|
50
|
-
"json-immutability-helper": "
|
|
50
|
+
"json-immutability-helper": "4.x",
|
|
51
51
|
"lean-test": "2.x",
|
|
52
52
|
"prettier": "3.3.3",
|
|
53
53
|
"rollup": "4.x",
|
|
54
54
|
"rollup-plugin-dts": "6.x",
|
|
55
55
|
"superwstest": "2.x",
|
|
56
56
|
"tslib": "2.7.x",
|
|
57
|
-
"typescript": "5.
|
|
57
|
+
"typescript": "5.6.x",
|
|
58
58
|
"websocket-express": "3.x"
|
|
59
59
|
}
|
|
60
60
|
}
|