{"id":15415831,"url":"https://github.com/davidje13/shared-reducer","last_synced_at":"2026-02-08T21:01:52.024Z","repository":{"id":254873559,"uuid":"847642170","full_name":"davidje13/shared-reducer","owner":"davidje13","description":"Shared state management via websockets.","archived":false,"fork":false,"pushed_at":"2025-02-09T16:27:37.000Z","size":156,"stargazers_count":0,"open_issues_count":2,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-08-09T15:56:01.533Z","etag":null,"topics":["reducer","websocket"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/davidje13.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2024-08-26T09:04:17.000Z","updated_at":"2025-02-09T16:27:40.000Z","dependencies_parsed_at":"2024-08-26T18:39:19.629Z","dependency_job_id":"015975be-eb9c-4326-85b9-a962694e43ca","html_url":"https://github.com/davidje13/shared-reducer","commit_stats":{"total_commits":34,"total_committers":1,"mean_commits":34.0,"dds":0.0,"last_synced_commit":"979d1d1793f2b7b6856c79124a828dd3f676f94e"},"previous_names":["davidje13/shared-reducer"],"tags_count":7,"template":false,"template_full_name":null,"purl":"pkg:github/davidje13/shared-reducer","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/davidje13%2Fshared-reducer","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/davidje13%2Fshared-reducer/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/davidje13%2Fshared-reducer/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/davidje13%2Fshared-reducer/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/davidje13","download_url":"https://codeload.github.com/davidje13/shared-reducer/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/davidje13%2Fshared-reducer/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":270014254,"owners_count":24512612,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-08-12T02:00:09.011Z","response_time":80,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["reducer","websocket"],"created_at":"2024-10-01T17:09:52.178Z","updated_at":"2026-02-08T21:01:52.018Z","avatar_url":"https://github.com/davidje13.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Shared Reducer\n\nShared state management via websockets.\n\nDesigned to work with\n[json-immutability-helper](https://github.com/davidje13/json-immutability-helper).\n\n## Install dependency\n\n```bash\nnpm install --save shared-reducer json-immutability-helper\n```\n\n(if you want to use an alternative reducer, see the instructions below).\n\n## Usage (Backend)\n\nThis project is compatible with [web-listener](https://github.com/davidje13/web-listener) and\n[websocket-express](https://github.com/davidje13/websocket-express), but can also be used in\nisolation.\n\n### With web-listener\n\n```javascript\nimport {\n  Broadcaster,\n  WebsocketHandlerFactory,\n  InMemoryModel,\n  ReadWrite,\n} from 'shared-reducer/backend';\nimport context from 'json-immutability-helper';\nimport { WebListener, Router, makeAcceptWebSocket, setSoftCloseHandler } from 'web-listener';\nimport { WebSocketServer } from 'ws';\n\nconst model = new InMemoryModel();\nconst broadcaster = new Broadcaster(model, context);\nmodel.set('a', { foo: 'v1' });\n\nconst acceptWebSocket = makeAcceptWebSocket(WebSocketServer);\nconst handlerFactory = new WebsocketHandlerFactory(broadcaster);\n\nconst router = new Router();\nrouter.ws(\n  '/:id',\n  handlerFactory.handler({\n    accessGetter: (req) =\u003e ({\n      id: getPathParameter(req, 'id'),\n      permission: ReadWrite,\n    }),\n    acceptWebSocket,\n    setSoftCloseHandler,\n  }),\n);\n\nconst server = new WebListener(router).listen(0, 'localhost');\n\n// later, to shutdown gracefully:\n// send a close signal to all clients and wait up to 1 second for acknowledgement:\nawait server.closeWithTimeout('shutdown', 1000);\n```\n\nFor real use-cases, you will probably want to add authentication middleware to the router chain,\nand you may want to give some users read-only and others read-write access, which can be achieved in\nthe `accessGetter` lambda.\n\n### With websocket-express\n\n```javascript\nimport {\n  Broadcaster,\n  WebsocketHandlerFactory,\n  InMemoryModel,\n  ReadWrite,\n} from 'shared-reducer/backend';\nimport context from 'json-immutability-helper';\nimport { WebSocketExpress } from 'websocket-express';\n\nconst model = new InMemoryModel();\nconst broadcaster = new Broadcaster(model, context);\nmodel.set('a', { foo: 'v1' });\n\nconst handlerFactory = new WebsocketHandlerFactory(broadcaster);\nconst softClosers = new Map();\n\nconst app = new WebSocketExpress();\napp.ws(\n  '/:id',\n  handlerFactory.handler({\n    accessGetter: (req) =\u003e ({ id: req.params.id, permission: ReadWrite }),\n    acceptWebSocket: (_, res) =\u003e res.accept(),\n    setSoftCloseHandler: (req, fn) =\u003e softClosers.set(req, fn),\n    onDisconnect: (req) =\u003e softClosers.delete(req),\n  }),\n);\n\nconst server = app.listen(0, 'localhost');\n\n// later, to shutdown gracefully:\nserver.close(); // stop accepting new connections\n// send a close signal to all active clients:\nfor (const fn of softClosers.values()) {\n  fn();\n}\n// force-close connections after a time:\nsetTimeout(() =\u003e server.closeAllConnections(), 1000);\n```\n\nFor real use-cases, you will probably want to add authentication middleware to the expressjs chain,\nand you may want to give some users read-only and others read-write access, which can be achieved in\nthe `accessGetter` lambda.\n\n### Alone\n\n```javascript\nimport { Broadcaster, InMemoryModel } from 'shared-reducer/backend';\nimport context from 'json-immutability-helper';\n\nconst model = new InMemoryModel();\nconst broadcaster = new Broadcaster(model, context);\nmodel.set('a', { foo: 'v1' });\n\n// ...\n\nconst subscription = await broadcaster.subscribe('a');\n\nconst begin = subscription.getInitialData();\nsubscription.listen((change, meta) =\u003e {\n  // ...\n});\nawait subscription.send(['=', { foo: 'v2' }]);\n// callback provided earlier is invoked\n\nawait subscription.close();\n```\n\n### Persisting data\n\nA convenience wrapper is provided for use with\n[collection-storage](https://github.com/davidje13/collection-storage), or you can write your own\nimplementation of the `Model` interface to link any backend.\n\n```javascript\nimport { Broadcaster, CollectionStorageModel } from 'shared-reducer/backend';\nimport context from 'json-immutability-helper';\nimport CollectionStorage from 'collection-storage';\n\nconst db = await CollectionStorage.connect('memory://something');\nconst model = new CollectionStorageModel(\n  db.getCollection('foo'),\n  'id',\n  // a function which takes in an object and returns it if valid,\n  // or throws if invalid (protects stored data from malicious changes)\n  MY_VALIDATOR,\n);\nconst broadcaster = new Broadcaster(model, context);\n```\n\nNote that the provided validator MUST verify structural integrity (e.g. ensuring no unexpected\nfields are added or types are changed).\n\n## Usage (Frontend)\n\n```javascript\nimport { SharedReducer } from 'shared-reducer/frontend';\nimport context from 'json-immutability-helper';\n\nconst reducer = new SharedReducer(context, {\n  url: 'ws://destination',\n  token: 'my-token',\n});\n\nreducer.addStateListener((state) =\u003e {\n  console.log('latest state is', state);\n});\n\nreducer.addEventListener('connected', () =\u003e {\n  console.log('connected / reconnected');\n});\n\nreducer.addEventListener('disconnected', (e) =\u003e {\n  console.log('connection lost', e.detail.code, e.detail.reason);\n});\n\nreducer.addEventListener('warning', (e) =\u003e {\n  console.log('latest change failed', e.detail);\n});\n\nconst dispatch = reducer.dispatch;\n\ndispatch([{ a: ['=', 8] }]);\n\ndispatch([\n  (state) =\u003e {\n    return [{ a: ['=', Math.pow(2, state.a)] }];\n  },\n]);\n\ndispatch([\n  (state) =\u003e {\n    console.log('state after handling is', state);\n    return [];\n  },\n]);\n\ndispatch(\n  [{ a: ['add', 1] }],\n  (state) =\u003e console.log('state after syncing is', state),\n  (message) =\u003e console.warn('failed to sync', message),\n);\n\ndispatch([{ a: ['add', 1] }, { a: ['add', 1] }]);\n```\n\n### Specs\n\nThe specs need to match whichever reducer you are using. In the examples above, that is\n[json-immutability-helper](https://github.com/davidje13/json-immutability-helper).\n\n## WebSocket protocol\n\nThe websocket protocol is minimal:\n\n### Client-to-server\n\n- `\u003ctoken\u003e`: The authentication token is sent as the first message when the connection is\n  established. This is plaintext. The server should respond by either terminating the connection (if\n  the token is deemed invalid), or with an `init` event which defines the latest state in its\n  entirety. If no token is specified using `withToken`, no message will be sent (when not using\n  authentication, it is assumed the server will send the `init` event unprompted).\n\n- `P` (ping): Can be sent periodically to keep the connection alive. The server sends a \"Pong\"\n  message in response immediately.\n\n- `{\"change\": \u003cspec\u003e, \"id\": \u003cid\u003e}`: Defines a delta. This may contain the aggregate result of many\n  operations performed on the client. The ID is an opaque identifier which is reflected back to the\n  same client in the confirmation message. Other clients will not receive the ID.\n\n- `x` (close ack): Sent by the client in response to `X` (closing). Indicates that the client will\n  not send any more messages on this connection (but may still be expecting some responses to\n  existing messages).\n\n### Server-to-client\n\n- `p` (pong): Reponse to a ping. May also be sent unsolicited.\n\n- `{\"init\": \u003cstate\u003e}`: The first message sent by the server, in response to a successful connection.\n\n- `{\"change\": \u003cspec\u003e}`: Sent whenever another client has changed the server state.\n\n- `{\"change\": \u003cspec\u003e, \"id\": \u003cid\u003e}`: Sent whenever the current client has changed the server state.\n  Note that the spec and ID will match the client-sent values.\n\n  The IDs sent by different clients can coincide, so the ID is only reflected to the client which\n  sent the spec.\n\n- `{\"error\": \u003cmessage\u003e, \"id\": \u003cid\u003e}`: Sent if the server rejects a client-initiated change.\n\n  If this is returned, the server state will not have changed (i.e. the entire spec failed).\n\n- `X` (closing): Sent when the server is about to shut down. The client should respond with `x` and\n  not send any more messages on the current connection. Any currently in-flight messages will be\n  acknowledged on a best-effort basis by the server. The server might not wait for the acknowledging\n  `x` message before closing the connection.\n\n### Specs\n\nThe specs need to match whichever reducer you are using. In the examples above, that is\n[json-immutability-helper](https://github.com/davidje13/json-immutability-helper).\n\n## Alternative reducer\n\nTo enable different features of `json-immutability-helper`, you can customise it before passing it\nto the constructor. For example, to enable list commands such as `updateWhere` and mathematical\ncommands such as Reverse Polish Notation (`rpn`):\n\n```javascript\n// Backend\nimport { Broadcaster, InMemoryModel } from 'shared-reducer/backend';\nimport listCommands from 'json-immutability-helper/commands/list';\nimport mathCommands from 'json-immutability-helper/commands/math';\nimport context from 'json-immutability-helper';\n\nconst broadcaster = new Broadcaster(new InMemoryModel(), context.with(listCommands, mathCommands));\n```\n\n```javascript\n// Frontend\nimport { SharedReducer } from 'shared-reducer/frontend';\nimport listCommands from 'json-immutability-helper/commands/list';\nimport mathCommands from 'json-immutability-helper/commands/math';\nimport context from 'json-immutability-helper';\n\nconst reducer = new SharedReducer(context.with(listCommands, mathCommands), {\n  url: 'ws://destination',\n});\n```\n\nIf you want to use an entirely different reducer, create a wrapper:\n\n```javascript\nimport context from 'json-immutability-helper';\n\nconst myReducer = {\n  update: (value, spec) =\u003e {\n    // return a new value which is the result of applying\n    // the given spec to the given value (or throw an error)\n  },\n  combine: (specs) =\u003e {\n    // return a new spec which is equivalent to applying\n    // all the given specs in order\n  },\n};\n\n// Backend\nconst broadcaster = new Broadcaster(new InMemoryModel(), myReducer);\n\n// Frontend\nconst reducer = new SharedReducer(myReducer, { url: 'ws://destination' });\n```\n\nBe careful when using your own reducer to avoid introducing security vulnerabilities; the functions\nwill be called with untrusted input, so should be careful to avoid attacks such as code injection or\nprototype pollution.\n\n## Other customisations (Backend)\n\nThe `Broadcaster` constructor can also take some optional arguments:\n\n```javascript\nnew Broadcaster(model, reducer[, options]);\n```\n\n- `options.subscribers`: specify a custom keyed broadcaster, used for communicating changes to all\n  consumers. Required interface:\n\n  ```javascript\n  {\n    add(key, listener) {\n      // add the listener function to key\n    },\n    remove(key, listener) {\n      // remove the listener function from key\n    },\n    broadcast(key, message) {\n      // call all current listener functions for key with\n      // the parameter message\n    },\n  }\n  ```\n\n  All functions can be asynchronous or synchronous.\n\n  The main use-case for overriding this would be to share messages between multiple servers for load\n  balancing, but note that in most cases you probably want to load balance _documents_ rather than\n  _users_ for better scalability.\n\n- `options.taskQueues`: specify a custom task queue, used to ensure operations happen in the correct\n  order. Required interface:\n\n  ```javascript\n  {\n    push(key, task) {\n      // add the (possibly asynchronous) task to the queue\n      // for the given key\n    },\n  }\n  ```\n\n  The default implementation will execute the task if it is the first task in a particular queue. If\n  there is already a task in the queue, it will be stored and executed once the existing tasks have\n  finished. Once all tasks for a particular key have finished, it will remove the queue.\n\n  As with `subscribers`, the main reason to override this is to provide consistency if multiple\n  servers are able to modify the same document simultaneously.\n\n- `options.idProvider`: specify a custom unique ID provider. Must be a function which returns a\n  unique string ID when called. Can be asynchronous.\n\n  The returned ID is used internally and passed through the configured `taskQueues` to identify the\n  source of a change. It is not revealed to users. The default implementation uses a fixed random\n  prefix followed by an incrementing number, which should be sufficient for most use cases.\n\n## Other customisations (Frontend)\n\nIf the connection is lost, the frontend will attempt to reconnect automatically. By default this\nuses an exponential backoff with a small amount of randomness, as well as attempting to connect if\nthe page regains focus or the computer rejoins a network. You can fully customise this behaviour:\n\n```javascript\nimport { SharedReducer, OnlineScheduler, exponentialDelay } from 'shared-reducer/frontend';\n\nconst reducer = new SharedReducer(\n  context,\n  { url: 'ws://destination' },\n  {\n    scheduler: new OnlineScheduler(\n      exponentialDelay({\n        base: 2,\n        initialDelay: 200,\n        maxDelay: 10 * 60 * 1000,\n        randomness: 0.3,\n      }),\n      20 * 1000, // timeout for each connection attempt\n    ),\n  },\n);\n```\n\nThe `exponentialDelay` helper returns:\n\n```\nmin(initialDelay * (base ^ attempt), maxDelay) * (1 - random(randomness))\n```\n\nAll delay values are in milliseconds.\n\nYou can also provide a custom function instead of `exponentialDelay`; it will be given the current\nattempt number (0-based), and should return the number of milliseconds to wait before triggering the\nattempt.\n\nIf you need to reauthenticate (e.g. due to an expired token), you can listen for the `'rejected'`\nevent and call `reconnect` with a new token (or a new URL):\n\n```javascript\nconst reducer = new SharedReducer(context, { url: 'ws://destination', token: 'my-initial-token' });\nreducer.addEventListener('rejected', (e) =\u003e {\n  if (e.detail.code === 4401) {\n    // example websocket code sent by server when rejecting the auth\n    e.preventDefault(); // do not automatically retry\n\n    // these steps do not need to be performed synchronously;\n    // just call .reconnect once you have a new token to use\n    const password = prompt('Enter the new password');\n    reducer.reconnect({ url: 'ws://destination', token: tokenFromPassword(password) });\n  }\n});\n```\n\nFinally, by default when reconnecting `SharedReducer` will replay all messages which have not been\nconfirmed (`AT_LEAST_ONCE` delivery). You can change this to `AT_MOST_ONCE` or a custom mechanism:\n\n```javascript\nimport { SharedReducer, AT_MOST_ONCE } from 'shared-reducer/frontend';\n\nconst reducer = new SharedReducer(\n  context,\n  { url: 'ws://destination' },\n  { deliveryStrategy: AT_MOST_ONCE },\n);\n```\n\nCustom strategies can be defined as functions:\n\n```javascript\nfunction myCustomDeliveryStrategy(serverState, spec, hasSent) {\n  return true; // re-send all (equivalent to AT_LEAST_ONCE)\n}\n```\n\n- `serverState` is the new state from the server after reconnecting.\n- `spec` is a spec that has not been confirmed as delivered to the server.\n- `hasSent` is `true` if the spec has already been sent to the server (but no delivery confirmation\n  was received). It is `false` if the message was never sent to the server.\n\nNote that the function will be invoked multiple times (once for each change that is pending). It\nshould return `true` for messages to resend, and `false` for messages to drop.\n\n## Older versions\n\nFor older versions of this library, see the separate\n[backend](https://github.com/davidje13/shared-reducer-backend) and\n[frontend](https://github.com/davidje13/shared-reducer-frontend) repositories.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdavidje13%2Fshared-reducer","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdavidje13%2Fshared-reducer","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdavidje13%2Fshared-reducer/lists"}