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 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 };
@@ -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
+ }