├── .formatter.exs ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── config ├── config.exs └── test.exs ├── lib ├── storex.ex └── storex │ ├── diff.ex │ ├── handler │ ├── cowboy.ex │ └── plug.ex │ ├── http.ex │ ├── message.ex │ ├── pg.ex │ ├── plug.ex │ ├── registry.ex │ ├── socket.ex │ ├── store.ex │ └── supervisor.ex ├── mix.exs ├── mix.lock ├── package.json ├── priv └── static │ ├── storex.cjs.js │ ├── storex.d.ts │ ├── storex.esm.js │ └── storex.umd.js └── test ├── fixtures ├── browser │ ├── browser_test.html │ ├── handler.ex │ └── plug.ex ├── node.mjs └── stores │ ├── counter.ex │ ├── error_init.ex │ ├── key_init.ex │ └── text.ex ├── storex ├── browser │ ├── bandit_test.exs │ ├── cowboy_test.exs │ └── plug_test.exs ├── diff_test.exs ├── handler │ ├── cowboy_test.exs │ └── plug_test.exs ├── node_test.exs ├── plug_test.exs └── storex_test.exs ├── support └── handler_helpers.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | test/* linguist-vendored -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | push: 7 | tags: 8 | - 0.* 9 | - 1.* 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-22.04 14 | env: 15 | MIX_ENV: test 16 | name: Elixir ${{matrix.version.elixir}} / OTP ${{matrix.version.otp}} 17 | strategy: 18 | matrix: 19 | version: 20 | [ 21 | { elixir: "1.14", otp: "24" }, 22 | { elixir: "1.14", otp: "25" }, 23 | 24 | { elixir: "1.15", otp: "24" }, 25 | { elixir: "1.15", otp: "25" }, 26 | { elixir: "1.15", otp: "26" }, 27 | 28 | { elixir: "1.16", otp: "24" }, 29 | { elixir: "1.16", otp: "25" }, 30 | { elixir: "1.16", otp: "26" }, 31 | 32 | { elixir: "1.17", otp: "25" }, 33 | { elixir: "1.17", otp: "26" }, 34 | { elixir: "1.17", otp: "27" }, 35 | ] 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: erlef/setup-beam@v1 39 | with: 40 | otp-version: ${{matrix.version.otp}} 41 | elixir-version: ${{matrix.version.elixir}} 42 | - uses: actions/setup-node@v4 43 | with: 44 | node-version: 20 45 | - uses: actions/cache@v4 46 | with: 47 | path: deps 48 | key: ${{ runner.os }}-${{matrix.version.otp}}-${{matrix.version.elixir}}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 49 | restore-keys: | 50 | ${{ runner.os }}-${{matrix.version.otp}}-${{matrix.version.elixir}}-mix- 51 | - name: Install Dependencies 52 | run: | 53 | epmd -daemon 54 | mix local.rebar --force 55 | mix local.hex --force 56 | mix deps.get 57 | - name: Run Tests 58 | run: mix test 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | storex-*.tar 24 | 25 | /.elixir_ls -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # storex 2 | 3 | ## 0.6.1 4 | 5 | - Add missing `cast` to `Storex.Message` 6 | 7 | ## 0.6.0 8 | 9 | - **[BREAKING]** Keys of params in Store are now type `binary` instead of `atom` 10 | - Added message validation with structured casting 11 | 12 | ## 0.5.1 13 | 14 | - Fix frontend client type for `commit` 15 | 16 | ## 0.5.0 17 | 18 | - **[BREAKING]** Frontend client fully rewritten 19 | - **[BREAKING]** Updated the minimum required version of Elixir to `1.10` 20 | - Added support for non browser environment 21 | 22 | ## 0.4.0 23 | 24 | - **[BREAKING]** `Storex.mutate/3` is no longer based on `session_id` 25 | - **[BREAKING]** `Store.init/2` callback now need to return `{:ok, state} | {:ok, state, key} | {:error, reason}` 26 | - **[BREAKING]** Remove custom `Registry` logic 27 | - **[BREAKING]** Remove `connection` callback from javascript client 28 | - New registry mechanism provides distributed mutations across the cluster 29 | - Fix `terminate` callback in `Storex.Handler.Plug` 30 | - Added three callbacks to frontend client `onConnected`, `onError` and `onDisconnected` 31 | 32 | ## 0.3.0 33 | 34 | - **[BREAKING]** Rename Cowbow handler module from `Storex.Socket.Handler` to `Storex.Handler.Cowboy` 35 | - Add support for Plug based apps `plug Storex.Plug` 36 | - Update Storex application supervisor children spec 37 | 38 | ## 0.2.5 39 | 40 | - Fix diff of Date struct 41 | - Rewrite tests from Hound to Wallaby 42 | 43 | ## 0.2.4 44 | 45 | - Fix root state update 46 | - Remove optional from jason dependency 47 | 48 | ## 0.2.3 49 | 50 | - Fix reconnect of WebSocket on connection close 51 | 52 | ## 0.2.2 53 | 54 | - Fix reconnect of WebSocket on connection close 55 | 56 | ## 0.2.1 57 | 58 | - Typescript/Javascript improvements 59 | 60 | ## 0.2.0 61 | 62 | - Dynamic registry declaration 63 | - - Default registry on ETS 64 | - Fix issue with a restart of Store when stopped on disconnect 65 | - Update dependencies 66 | 67 | ## 0.1.0 68 | 69 | - The only diff of the store state is being sent on each mutation. 70 | - Subscriber of connection status 71 | - Fixes in library 72 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019 - 2020 NERDSLABS 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Storex 2 | 3 | ![Elixir CI](https://github.com/nerdslabs/storex/workflows/Elixir%20CI/badge.svg) [![Downloads](https://img.shields.io/hexpm/dt/storex.svg)](https://hex.pm/packages/storex) 4 | 5 | Storex is a frontend store with state management handled on the backend. It allows you to update the store state both from the frontend and backend, with all communication occurring over WebSocket. 6 | 7 | Important: Storex is currently under active development. We encourage you to report any issues or submit feature requests [here](https://github.com/nerdslabs/storex/issues/new). 8 | 9 | ## Why Storex? 10 | 11 | ### Features 12 | 13 | - Efficient state management: Only the differences (diffs) in the store state are sent with each mutation, minimizing data transfer. 14 | - Real-time updates: State changes are immediately reflected across all connected clients via WebSocket communication. 15 | - Backend-driven state: Storex allows both the frontend and backend to update the store state seamlessly. 16 | - Lightweight and fast: Designed for minimal overhead, ensuring rapid state updates and communication. 17 | 18 | ### Key Differences from Phoenix LiveView 19 | 20 | Phoenix LiveView is a powerful tool for building rich, interactive web applications without writing custom JavaScript. However, as your application grows, managing complex client-side state across multiple LiveViews or components can become challenging. This is where Storex comes in. 21 | 22 | - Client-Side State Management: While Phoenix LiveView handles server-side rendering and event handling, Storex focuses on managing state on the client side. It allows you to keep your client-side state in sync with the server, but with more flexibility in how that state is stored, updated, and accessed. 23 | - Decoupled State Logic: Storex decouples state management from the LiveView itself, enabling you to manage state across multiple components or even across the entire application. This contrasts with LiveView, where state is typically tied to a specific LiveView process. 24 | - Predictable State Updates: Storex follows a predictable, unidirectional data flow similar to Redux. This makes it easier to reason about state changes and debug issues, especially in complex applications. 25 | - Extensibility: Storex is designed to be highly extensible, allowing you to integrate it with other tools and libraries in the Elixir ecosystem. You can also define custom middleware to handle side effects, logging, or other tasks. 26 | 27 | For an overview of Storex in action, check out the example provided [here](https://storex-phoenix-ssr-spring-sky-1412.fly.dev/). 28 | 29 | ## Basic usage 30 | 31 | ### Installation 32 | 33 | Add **storex** to deps in `mix.exs`: 34 | 35 | ```elixir 36 | defp deps do 37 | [{:storex, "~> 0.6"}] 38 | end 39 | ``` 40 | 41 | Also you need to add **storex** to `package.json` dependencies: 42 | 43 | ```javascript 44 | { 45 | "storex": "file:../deps/storex", 46 | } 47 | ``` 48 | 49 | ### Add storex websocket handler 50 | 51 | You need to add handler `Storex.Handler.Plug` or `Storex.Handler.Cowboy`. 52 | 53 | **Phoenix:** 54 | ```elixir 55 | defmodule YourAppWeb.Endpoint do 56 | use Phoenix.Endpoint, otp_app: :your_app 57 | 58 | plug Storex.Plug, path: "/storex" 59 | 60 | # ... 61 | end 62 | ``` 63 | 64 | **Cowboy:** 65 | ```elixir 66 | :cowboy_router.compile([ 67 | {:_, [ 68 | # ... 69 | {"/storex", Storex.Handler.Cowboy, []}, 70 | # ... 71 | ]} 72 | ]) 73 | ``` 74 | 75 | > [!IMPORTANT] 76 | > Cowboy doesn't support the Node.js (HTTP Only) connector 77 | 78 | ### Create store 79 | 80 | To create a store you need to create new elixir module with `init/2` which is called when a page is loaded, every time websocket is connected it generates session_id and passes it as the first argument, params are from Javascript store declaration. `init/2` callback need to return one of this tuples: 81 | 82 | - `{:ok, state}` - for initial state 83 | - `{:ok, state, key}` - for initial state with `key` which can be used as selector for future mutations 84 | - `{:error, reason}` - to send error message to frontend on initialization 85 | 86 | Next, you can declare `mutation/5` where the first argument is mutation name, second is data passed to mutation, next two params are same like in `init/2`, the last one is the current state of the store. 87 | 88 | ```elixir 89 | defmodule ExampleApp.Store.Counter do 90 | use Storex.Store 91 | 92 | def init(session_id, _params) do 93 | {:ok, 0} 94 | end 95 | 96 | # Params have binary keys and result of `init/2` function can return `key` which will be the key for mutations. 97 | # def init(session_id, %{"someKey" => someKeyValue}) do 98 | # {:ok, 0, someKeyValue} 99 | # end 100 | 101 | # `increase` is mutation name, `data` is payload from front-end, `session_id` is current session id of connecton, `initial_params` with which store was initialized, `state` is store current state. 102 | def mutation("increase", _data, _session_id, _initial_params, state) do 103 | state = state + 1 104 | 105 | {:noreply, state} 106 | end 107 | 108 | def mutation("decrease", _data, _session_id, _initial_params, state) do 109 | state = state - 1 110 | 111 | {:reply, "message", state} 112 | end 113 | 114 | def mutation("set", [number], _session_id, _initial_params, state) do 115 | {:noreply, number} 116 | end 117 | end 118 | ``` 119 | 120 | ### Connect to store 121 | 122 | You have to connect the newly created store with a frontend side to be able to synchronise the state: `params` are passed as second argument in store `init/2` and as third in `mutation/5`. You can subscribe to changes inside store state by passing option `subscribe` with function as a value. 123 | 124 | ```typescript 125 | import useStorex from 'storex' 126 | 127 | const store = useStorex({ 128 | store: 'ExampleApp.Store.Counter', 129 | params: {} 130 | }) 131 | ``` 132 | 133 | ### Mutate store 134 | 135 | You can mutate store from javascript with store instance: 136 | 137 | ```typescript 138 | store.commit("increase") 139 | store.commit("decrease").then((response) => { 140 | response // Reply from elixir 141 | }) 142 | store.commit("set", 10) 143 | ``` 144 | 145 | Or directly from elixir: 146 | 147 | ```elixir 148 | Storex.mutate(store, "increase", []) 149 | Storex.mutate(store, "set", [10]) 150 | Storex.mutate(key, store, "increase", []) 151 | Storex.mutate(key, store, "set", [10]) 152 | ``` 153 | 154 | ### Subscribe to store changes 155 | 156 | You can subscribe to store state changes in javascript with function subscribe: 157 | 158 | ```typescript 159 | store.subscribe((state) => { 160 | const state = state 161 | }) 162 | ``` 163 | 164 | You can also subscribe to events after store is created: 165 | 166 | ```typescript 167 | store.onConnected(() => { 168 | console.log('connected') 169 | }) 170 | 171 | store.onError((error) => { 172 | console.log('error', error) 173 | }) 174 | 175 | store.onDisconnected((closeEvent) => { 176 | console.log('disconnected', closeEvent) 177 | }) 178 | ``` 179 | 180 | ## Connectors 181 | The default export of `useStorex` uses WebSocket connections only, you can extend it by using custom connector. 182 | 183 | ### Websocket 184 | 185 | ```typescript 186 | import { prepare, socketConnector } from 'storex'; 187 | 188 | const connector = socketConnector({ address: 'wss://myapi.com/storex' }); 189 | const { useStorex } = prepare({ /* global params */ }, connector); 190 | 191 | const myStore = useStorex({ 192 | store: 'myStoreName', 193 | params: { /* store-specific params */ } 194 | }); 195 | ``` 196 | 197 | ### Node.js (HTTP Only) 198 | 199 | **Node.js connector require Node.js installed on server which running application** 200 | 201 | ```typescript 202 | import { prepare, httpConnector } from 'storex'; 203 | 204 | const connector = httpConnector({ address: 'http://myapi.com/storex' }); 205 | const { useStorex } = prepare({}, connector); 206 | 207 | const myStore = useStorex({ 208 | store: 'myStoreName', 209 | params: { /* store-specific params */ } 210 | }); 211 | 212 | // Subscribe to state changes 213 | myStore.subscribe((state) => { 214 | console.log('New state:', state); 215 | }); 216 | 217 | // Handle errors 218 | myStore.onError((error) => { 219 | console.error('An error occurred:', error); 220 | }); 221 | ``` 222 | 223 | > [!IMPORTANT] 224 | > Mutations are not supported in HTTP mode 225 | > myStore.commit() will not work as expected 226 | 227 | ## Configuration 228 | 229 | ### Session id generation library 230 | 231 | You can change library which generate session id for stores. Module needs to have **generate/0** method. 232 | 233 | ```elixir 234 | config :storex, :session_id_library, Ecto.UUID 235 | ``` 236 | 237 | ### Default params 238 | 239 | You can set default params for all stores when preparing the Storex instance. These params will be passed to each store. 240 | 241 | ```typescript 242 | const { useStorex } = prepare({ jwt: 'someJWT' }, connector); 243 | ``` 244 | 245 | ### Custom store address 246 | 247 | You can specify a custom address when creating the connector: 248 | 249 | ```typescript 250 | const connector = socketConnector({ address: 'wss://myapi.com/storex' }); 251 | // OR 252 | const connector = httpConnector({ address: 'http://myapi.com/storex' }); 253 | ``` -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :storex, 4 | session_id_library: Nanoid, 5 | registry: Storex.Registry.ETS 6 | 7 | if Mix.env() == :test do 8 | import_config "test.exs" 9 | end 10 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :wallaby, 4 | otp_app: :storex, 5 | chromedriver: [ 6 | # headless: false 7 | ] 8 | -------------------------------------------------------------------------------- /lib/storex.ex: -------------------------------------------------------------------------------- 1 | defmodule Storex do 2 | use Application 3 | 4 | @doc false 5 | def start(_type, _args) do 6 | import Supervisor.Spec, warn: false 7 | 8 | children = 9 | pg_children() ++ 10 | [ 11 | {Storex.PG, []}, 12 | {Storex.Registry, []}, 13 | {Storex.Supervisor, []} 14 | ] 15 | 16 | Supervisor.start_link(children, strategy: :one_for_one, name: __MODULE__) 17 | end 18 | 19 | if Code.ensure_loaded?(:pg) do 20 | defp pg_children() do 21 | [%{id: :pg, start: {:pg, :start_link, [Storex.PG]}}] 22 | end 23 | else 24 | defp pg_children() do 25 | [] 26 | end 27 | end 28 | 29 | @doc """ 30 | Mutate store from elixir. 31 | 32 | Invoke mutation callback globally across specified store asynchronously: 33 | ```elixir 34 | Storex.mutate("ExampleApp.Store", "reload", ["params"]) 35 | ``` 36 | """ 37 | def mutate(store, mutation, payload) do 38 | Storex.PG.broadcast({:mutate, store, mutation, payload}) 39 | end 40 | 41 | @doc """ 42 | Mutate store from elixir. 43 | 44 | Invoke mutation callback by specified key and store asynchronously: 45 | ```elixir 46 | Storex.mutate("user_id", "ExampleApp.Store", "reload", ["params"]) 47 | ``` 48 | """ 49 | def mutate(key, store, mutation, payload) do 50 | Storex.PG.broadcast({:mutate, key, store, mutation, payload}) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/storex/diff.ex: -------------------------------------------------------------------------------- 1 | defmodule Storex.Diff do 2 | @doc """ 3 | Check difference between two arguments. 4 | 5 | ```elixir 6 | Storex.Diff.check(%{name: "John"}, %{name: "Adam"}) 7 | [%{a: "u", p: [:name], t: "Adam"}] 8 | ``` 9 | 10 | Result explanation: 11 | ``` 12 | a: action 13 | n - none 14 | u - update 15 | d - delete 16 | i - insert 17 | t: to 18 | p: path 19 | ``` 20 | """ 21 | 22 | def check(source, changed) do 23 | diff(source, changed, [], []) 24 | end 25 | 26 | defp diff(source, changed, changes, path) when is_list(source) and is_list(changed) do 27 | source = Enum.with_index(source) 28 | changed = Enum.with_index(changed) 29 | compare_list(source, changed, changes, path) 30 | end 31 | 32 | defp diff(source, changed, changes, path) when is_map(source) and is_map(changed) do 33 | compare_map(source, changed, changes, path) 34 | end 35 | 36 | defp diff(source, changed, changes, path) do 37 | if source === changed do 38 | changes 39 | else 40 | [%{a: "u", t: changed, p: path} | changes] 41 | end 42 | end 43 | 44 | defp compare_list([{l, li} | lt], [{r, _ri} | rt], changes, path) do 45 | changes = diff(l, r, changes, path ++ [li]) 46 | compare_list(lt, rt, changes, path) 47 | end 48 | 49 | defp compare_list([{_l, li} | lt], [], changes, path) do 50 | changes = [%{a: "d", p: path ++ [li]} | changes] 51 | compare_list(lt, [], changes, path) 52 | end 53 | 54 | defp compare_list([], [{r, ri} | rt], changes, path) do 55 | changes = [%{a: "i", t: r, p: path ++ [ri]} | changes] 56 | compare_list([], rt, changes, path) 57 | end 58 | 59 | defp compare_list([], [], changes, _), do: changes 60 | 61 | defp compare_map(%NaiveDateTime{} = source, %NaiveDateTime{} = changed, changes, path) do 62 | source = NaiveDateTime.to_string(source) 63 | changed = NaiveDateTime.to_string(changed) 64 | 65 | diff(source, changed, changes, path) 66 | end 67 | 68 | defp compare_map(%DateTime{} = source, %DateTime{} = changed, changes, path) do 69 | source = DateTime.to_string(source) 70 | changed = DateTime.to_string(changed) 71 | 72 | diff(source, changed, changes, path) 73 | end 74 | 75 | defp compare_map(%Date{} = source, %Date{} = changed, changes, path) do 76 | source = Date.to_string(source) 77 | changed = Date.to_string(changed) 78 | 79 | diff(source, changed, changes, path) 80 | end 81 | 82 | defp compare_map(%{__struct__: _} = source, %{__struct__: _} = changed, changes, path) do 83 | source = Map.from_struct(source) 84 | changed = Map.from_struct(changed) 85 | 86 | compare_map(source, changed, changes, path) 87 | end 88 | 89 | defp compare_map(%{__struct__: _} = source, %{} = changed, changes, path) do 90 | source = Map.from_struct(source) 91 | 92 | compare_map(source, changed, changes, path) 93 | end 94 | 95 | defp compare_map(%{} = source, %{__struct__: _} = changed, changes, path) do 96 | changed = Map.from_struct(changed) 97 | 98 | compare_map(source, changed, changes, path) 99 | end 100 | 101 | defp compare_map(%{} = source, %{} = changed, changes, path) do 102 | changes = Enum.reduce(source, changes, &compare_map(&1, &2, changed, path, true)) 103 | Enum.reduce(changed, changes, &compare_map(&1, &2, source, path, false)) 104 | end 105 | 106 | defp compare_map({key, value}, acc, changed, path, true) do 107 | case Map.has_key?(changed, key) do 108 | false -> 109 | [%{a: "d", p: path ++ [key]} | acc] 110 | 111 | true -> 112 | changed_value = Map.get(changed, key) 113 | diff(value, changed_value, acc, path ++ [key]) 114 | end 115 | end 116 | 117 | defp compare_map({key, value}, acc, source, path, false) do 118 | case Map.has_key?(source, key) do 119 | false -> [%{a: "i", t: value, p: path ++ [key]} | acc] 120 | true -> acc 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/storex/handler/cowboy.ex: -------------------------------------------------------------------------------- 1 | defmodule Storex.Handler.Cowboy do 2 | @moduledoc false 3 | 4 | alias Storex.Socket 5 | 6 | def init(request, _state) do 7 | session = Application.get_env(:storex, :session_id_library, Nanoid).generate() 8 | 9 | {:cowboy_websocket, request, %{session: session, pid: request.pid}} 10 | end 11 | 12 | def websocket_init(_type, req, _opts) do 13 | {:ok, req, %{status: "inactive"}} 14 | end 15 | 16 | def terminate(_reason, _req, %{session: session}) do 17 | Storex.Registry.session_stores(session) 18 | |> Enum.each(fn {store, _, session, _, _} -> 19 | Storex.Supervisor.remove_store(session, store) 20 | end) 21 | 22 | :ok 23 | end 24 | 25 | def terminate(_, _, _) do 26 | :ok 27 | end 28 | 29 | def websocket_handle({:binary, frame}, state) do 30 | try do 31 | :erlang.binary_to_term(frame) 32 | |> Socket.message_handle(state) 33 | |> map_response() 34 | rescue 35 | ArgumentError -> {:reply, {:close, 1007, "Payload is malformed."}, state} 36 | end 37 | end 38 | 39 | def websocket_handle({:text, frame}, state) do 40 | with {:ok, decoded_message} <- Jason.decode(frame), 41 | {:ok, cast_message} <- Storex.Message.cast(decoded_message) do 42 | Socket.message_handle(cast_message, state) 43 | |> map_response() 44 | else 45 | {:error, _} -> 46 | {:reply, {:close, 1007, "Payload is malformed."}, state} 47 | end 48 | end 49 | 50 | def websocket_info({:mutate, store, mutation, data}, %{session: session} = state) do 51 | %{ 52 | type: "mutation", 53 | session: session, 54 | store: store, 55 | data: %{ 56 | data: data, 57 | name: mutation 58 | } 59 | } 60 | |> Socket.message_handle(state) 61 | |> map_response() 62 | end 63 | 64 | def websocket_info(_info, state) do 65 | {:ok, state} 66 | end 67 | 68 | defp map_response({:text, message, state}) do 69 | {:reply, {:text, message}, state} 70 | end 71 | 72 | defp map_response({:close, code, message, state}) do 73 | {:reply, {:close, code, message}, state} 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/storex/handler/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule Storex.Handler.Plug do 2 | @moduledoc false 3 | 4 | alias Storex.Socket 5 | 6 | def init(_) do 7 | session = Application.get_env(:storex, :session_id_library, Nanoid).generate() 8 | pid = self() 9 | 10 | {:ok, %{session: session, pid: pid}} 11 | end 12 | 13 | def terminate(_reason, %{session: session}) do 14 | Storex.Registry.session_stores(session) 15 | |> Enum.each(fn {store, _, session, _, _} -> 16 | Storex.Supervisor.remove_store(session, store) 17 | end) 18 | 19 | :ok 20 | end 21 | 22 | def terminate(_, _) do 23 | :ok 24 | end 25 | 26 | def handle_in({message, [opcode: :text]}, state) do 27 | with {:ok, decoded_message} <- Jason.decode(message), 28 | {:ok, cast_message} <- Storex.Message.cast(decoded_message) do 29 | Socket.message_handle(cast_message, state) 30 | |> map_response() 31 | else 32 | {:error, _} -> 33 | {:stop, "Payload is malformed.", 1007, state} 34 | end 35 | end 36 | 37 | def handle_info({:mutate, store, mutation, data}, %{session: session} = state) do 38 | %{ 39 | type: "mutation", 40 | session: session, 41 | store: store, 42 | data: %{ 43 | data: data, 44 | name: mutation 45 | } 46 | } 47 | |> Socket.message_handle(state) 48 | |> map_response() 49 | end 50 | 51 | def handle_info(_info, state) do 52 | {:ok, state} 53 | end 54 | 55 | defp map_response({:text, message, state}) do 56 | {:push, {:text, message}, state} 57 | end 58 | 59 | defp map_response({:close, code, message, state}) do 60 | {:stop, :normal, {code, message}, state} 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/storex/http.ex: -------------------------------------------------------------------------------- 1 | defmodule Storex.HTTP do 2 | def init_store(store, params) do 3 | with {:store, {:ok, store_module}} <- {:store, store |> get_module()}, 4 | {:params, {:ok, params}} <- {:params, params |> get_params()}, 5 | {:state, {:ok, result}} <- {:state, get_state(store_module, params)} do 6 | {:ok, 7 | %{ 8 | type: "join", 9 | session: "SSR", 10 | store: store, 11 | data: result 12 | }} 13 | else 14 | {:store, {:error, _}} -> 15 | {:error, 16 | %{ 17 | type: "error", 18 | session: "SSR", 19 | store: store, 20 | error: "Store '#{inspect(store)}' is not defined or can't be compiled." 21 | }} 22 | 23 | {:state, {:error, message}} -> 24 | {:error, 25 | %{ 26 | type: "error", 27 | session: "SSR", 28 | store: store, 29 | error: message 30 | }} 31 | 32 | _ -> 33 | {:error, 34 | %{ 35 | type: "error", 36 | session: "SSR", 37 | store: store, 38 | error: "Unknown error" 39 | }} 40 | end 41 | end 42 | 43 | defp get_module(store) do 44 | try do 45 | module = Module.safe_concat([store]) 46 | {:ok, module} 47 | rescue 48 | ArgumentError -> {:error, :not_exists} 49 | end 50 | end 51 | 52 | defp get_params(params) do 53 | params 54 | |> Jason.decode() 55 | end 56 | 57 | defp get_state(module, params) do 58 | module 59 | |> apply(:init, ["SSR", params]) 60 | |> result() 61 | end 62 | 63 | defp result({:ok, state}) do 64 | {:ok, state} 65 | end 66 | 67 | defp result({:ok, state, _}) do 68 | {:ok, state} 69 | end 70 | 71 | defp result({:error, error_message}) do 72 | {:error, error_message} 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/storex/message.ex: -------------------------------------------------------------------------------- 1 | defmodule Storex.Message do 2 | @derive {Jason.Encoder, only: [:type, :store, :data, :request, :session]} 3 | defstruct [:type, :store, :data, :request, :session] 4 | 5 | def cast(%{"type" => "ping", "request" => request}) do 6 | {:ok, %__MODULE__{type: "ping", request: request}} 7 | end 8 | 9 | def cast(%{ 10 | "type" => "join", 11 | "store" => store, 12 | "data" => data, 13 | "request" => request, 14 | "session" => session 15 | }) do 16 | {:ok, %__MODULE__{type: "join", store: store, data: data, request: request, session: session}} 17 | end 18 | 19 | def cast(%{"type" => "join", "store" => store, "data" => data, "request" => request}) do 20 | {:ok, %__MODULE__{type: "join", store: store, data: data, request: request}} 21 | end 22 | 23 | def cast(%{ 24 | "type" => "mutation", 25 | "store" => store, 26 | "data" => %{"name" => name, "data" => data}, 27 | "request" => request, 28 | "session" => session 29 | }) do 30 | {:ok, 31 | %__MODULE__{ 32 | type: "mutation", 33 | store: store, 34 | data: %{name: name, data: data}, 35 | request: request, 36 | session: session 37 | }} 38 | end 39 | 40 | def cast(%{ 41 | "type" => "error", 42 | "store" => store, 43 | "data" => data, 44 | "request" => request, 45 | "session" => session 46 | }) do 47 | {:ok, 48 | %__MODULE__{type: "error", store: store, data: data, request: request, session: session}} 49 | end 50 | 51 | def cast(_) do 52 | {:error, "Unknown message type"} 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/storex/pg.ex: -------------------------------------------------------------------------------- 1 | defmodule Storex.PG do 2 | @moduledoc false 3 | use GenServer 4 | 5 | @name :storex_pg 6 | 7 | def start_link(_) do 8 | GenServer.start_link(__MODULE__, [], name: @name) 9 | end 10 | 11 | @impl true 12 | def init(_) do 13 | :ok = pg_join(@name) 14 | {:ok, @name} 15 | end 16 | 17 | def broadcast(payload) do 18 | pg_members(@name) 19 | |> case do 20 | {:error, _} -> 21 | :error 22 | 23 | pids -> 24 | for pid <- pids do 25 | send(pid, {:broadcast, payload}) 26 | end 27 | end 28 | end 29 | 30 | @impl true 31 | def handle_info({:broadcast, {:mutate, store, mutation, payload}}, state) do 32 | Storex.Registry.get_store_instances({store, :_, :_, :_, :_}) 33 | |> Enum.map(fn {^store, _, _, session_pid, _} -> 34 | Kernel.send(session_pid, {:mutate, store, mutation, payload}) 35 | end) 36 | 37 | {:noreply, state} 38 | end 39 | 40 | @impl true 41 | def handle_info({:broadcast, {:mutate, key, store, mutation, payload}}, state) do 42 | Storex.Registry.get_store_instances({store, :_, :_, :_, key}) 43 | |> Enum.each(fn {^store, _, _, session_pid, ^key} -> 44 | Kernel.send(session_pid, {:mutate, store, mutation, payload}) 45 | end) 46 | 47 | {:noreply, state} 48 | end 49 | 50 | @impl true 51 | def handle_info(_, state) do 52 | {:noreply, state} 53 | end 54 | 55 | if Code.ensure_loaded?(:pg) do 56 | defp pg_members(group) do 57 | :pg.get_members(Storex.PG, group) 58 | end 59 | else 60 | defp pg_members(group) do 61 | :pg2.get_members({:storex, group}) 62 | end 63 | end 64 | 65 | if Code.ensure_loaded?(:pg) do 66 | defp pg_join(group) do 67 | :ok = :pg.join(Storex.PG, group, self()) 68 | end 69 | else 70 | defp pg_join(group) do 71 | namespace = {:storex, group} 72 | :ok = :pg2.create(namespace) 73 | :ok = :pg2.join(namespace, self()) 74 | :ok 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/storex/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule Storex.Plug do 2 | import Plug.Conn 3 | 4 | @moduledoc """ 5 | Add Storex to your Plug application, to handle WebSocket connections. 6 | 7 | Example for Phoenix Endpoint: 8 | 9 | ```elixir 10 | defmodule YourAppWeb.Endpoint do 11 | use Phoenix.Endpoint, otp_app: :your_app 12 | 13 | plug Storex.Plug 14 | 15 | # ... 16 | end 17 | ``` 18 | 19 | ## Options 20 | 21 | - `:path` - The path to mount the Storex handler. Default is `"/storex"`. 22 | """ 23 | 24 | @doc false 25 | def init(options \\ []) do 26 | [ 27 | path: Keyword.get(options, :path, "/storex") 28 | ] 29 | end 30 | 31 | @doc false 32 | def call(%{method: "GET", request_path: path} = conn, path: path) do 33 | conn 34 | |> fetch_query_params() 35 | |> handle() 36 | end 37 | 38 | @doc false 39 | def call(conn, _) do 40 | conn 41 | end 42 | 43 | def handle( 44 | %{ 45 | method: "GET", 46 | query_params: %{"store" => encoded_store, "params" => encoded_params} 47 | } = conn 48 | ) do 49 | store = encoded_store |> URI.decode() 50 | params = encoded_params |> URI.decode() 51 | 52 | Storex.HTTP.init_store(store, params) 53 | |> case do 54 | {:ok, result} -> 55 | result 56 | |> Jason.encode!() 57 | |> (&resp(conn, 200, &1)).() 58 | 59 | {:error, error} -> 60 | error 61 | |> Jason.encode!() 62 | |> (&resp(conn, 400, &1)).() 63 | end 64 | |> put_resp_content_type("application/json") 65 | |> halt() 66 | end 67 | 68 | @doc false 69 | def handle(%{method: "GET"} = conn) do 70 | conn 71 | |> WebSockAdapter.upgrade(Storex.Handler.Plug, [], timeout: 60_000) 72 | |> halt() 73 | end 74 | 75 | @doc false 76 | def handle(conn) do 77 | conn 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/storex/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Storex.Registry do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | @registry :storex_registry 7 | 8 | def start_link(_) do 9 | GenServer.start_link(__MODULE__, nil, name: @registry) 10 | end 11 | 12 | def init(nil) do 13 | :ets.new(@registry, [:bag, :protected, :named_table]) 14 | 15 | {:ok, %{}} 16 | end 17 | 18 | def session_pid(session) do 19 | GenServer.call(@registry, {:session_pid, session}) 20 | end 21 | 22 | def register_store(store, store_pid, session, session_pid, key) do 23 | GenServer.call(@registry, {:register_store, store, store_pid, session, session_pid, key}) 24 | end 25 | 26 | def unregister_store(store, session) do 27 | GenServer.call(@registry, {:unregister_store, store, session}) 28 | end 29 | 30 | def get_store(store, session) do 31 | GenServer.call(@registry, {:get_store, store, session}) 32 | end 33 | 34 | def get_store_pid(store, session) do 35 | GenServer.call(@registry, {:get_store_pid, store, session}) 36 | end 37 | 38 | def get_store_instances(query) do 39 | GenServer.call(@registry, {:get_store_instances, query}) 40 | end 41 | 42 | def session_stores(session) do 43 | GenServer.call(@registry, {:session_stores, session}) 44 | end 45 | 46 | def handle_call({:session_pid, session}, _from, state) do 47 | :ets.match(@registry, {:_, session, :_, :"$1"}) 48 | |> case do 49 | [] -> {:reply, :undefined, state} 50 | [[pid] | _tail] -> {:reply, pid, state} 51 | end 52 | end 53 | 54 | def handle_call({:register_store, store, store_pid, session, session_pid, key}, _from, state) do 55 | :ets.insert(@registry, {store, store_pid, session, session_pid, key}) 56 | Process.monitor(store_pid) 57 | {:reply, {:ok, store_pid}, state} 58 | end 59 | 60 | def handle_call({:unregister_store, store, session}, _from, state) do 61 | result = :ets.match_delete(@registry, {store, :_, session, :_, :_}) 62 | {:reply, result, state} 63 | end 64 | 65 | def handle_call({:get_store, store, session}, _from, state) do 66 | :ets.match_object(@registry, {store, :"$1", session, :_, :_}) 67 | |> case do 68 | [] -> {:reply, :undefined, state} 69 | [object | _tail] -> {:reply, object, state} 70 | end 71 | end 72 | 73 | def handle_call({:get_store_pid, store, session}, _from, state) do 74 | :ets.match(@registry, {store, :"$1", session, :_, :_}) 75 | |> case do 76 | [] -> {:reply, :undefined, state} 77 | [[pid] | _tail] -> {:reply, pid, state} 78 | end 79 | end 80 | 81 | def handle_call({:get_store_instances, query}, _from, state) do 82 | instances = :ets.match_object(@registry, query) 83 | 84 | {:reply, instances, state} 85 | end 86 | 87 | def handle_call({:session_stores, session}, _from, state) do 88 | stores = :ets.match_object(@registry, {:_, :_, session, :_, :_}) 89 | 90 | {:reply, stores, state} 91 | end 92 | 93 | def handle_info({:DOWN, _ref, :process, pid, _reason}, _state) do 94 | :ets.match_delete(@registry, {:_, pid, :_, :_, :_}) 95 | 96 | {:noreply, :ok} 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/storex/socket.ex: -------------------------------------------------------------------------------- 1 | defmodule Storex.Socket do 2 | @moduledoc """ 3 | Socket connection handler. 4 | 5 | Error codes: 6 | - `4000`: Store is not set. 7 | - `4001`: Store is not defined or can't be compiled. 8 | """ 9 | 10 | @doc false 11 | def message_handle(%{type: "ping"} = message, state) do 12 | message = 13 | Map.put(message, :type, "pong") 14 | |> Jason.encode!() 15 | 16 | {:text, message, state} 17 | end 18 | 19 | def message_handle(%{store: nil}, state) do 20 | {:close, 4000, "Store is not set.", state} 21 | end 22 | 23 | def message_handle(%{type: "join"} = message, state) do 24 | with {:get_module, {:ok, _}} <- {:get_module, get_store_module(message.store)}, 25 | {:add_store, {:ok, _}} <- 26 | {:add_store, 27 | Storex.Supervisor.add_store(message.store, state.session, state.pid, message.data)} do 28 | store_state = Storex.Supervisor.get_store_state(state.session, message.store) 29 | 30 | message = 31 | Map.put(message, :data, store_state) 32 | |> Map.put(:session, state.session) 33 | |> Jason.encode!() 34 | 35 | {:text, message, state} 36 | else 37 | {:add_store, {:error, error_message}} -> 38 | %{ 39 | type: "error", 40 | session: state.session, 41 | store: message.store, 42 | error: error_message, 43 | request: Map.get(message, :request, nil) 44 | } 45 | |> Jason.encode!() 46 | |> (&{:text, &1, state}).() 47 | 48 | _ -> 49 | {:close, 4001, "Store '#{message.store}' is not defined or can't be compiled.", state} 50 | end 51 | end 52 | 53 | def message_handle(%{type: "mutation", session: session, store: store} = message, state) do 54 | Storex.Supervisor.mutate_store( 55 | message.session, 56 | message.store, 57 | message.data.name, 58 | message.data.data 59 | ) 60 | |> case do 61 | {:ok, diff} -> 62 | %{ 63 | type: "mutation", 64 | session: session, 65 | store: store, 66 | diff: diff, 67 | request: Map.get(message, :request, nil) 68 | } 69 | 70 | {:ok, reply_message, diff} -> 71 | %{ 72 | type: "mutation", 73 | session: session, 74 | store: store, 75 | diff: diff, 76 | message: reply_message, 77 | request: Map.get(message, :request, nil) 78 | } 79 | 80 | {:error, error} -> 81 | %{ 82 | type: "error", 83 | session: session, 84 | store: store, 85 | error: error, 86 | request: Map.get(message, :request, nil) 87 | } 88 | end 89 | |> Jason.encode!() 90 | |> (&{:text, &1, state}).() 91 | end 92 | 93 | defp safe_concat(store) do 94 | try do 95 | module = Module.safe_concat([store]) 96 | {:ok, module} 97 | rescue 98 | ArgumentError -> {:error, :not_exists} 99 | end 100 | end 101 | 102 | defp get_store_module(store) do 103 | with {:ok, module} <- safe_concat(store), 104 | {:module, module} <- Code.ensure_compiled(module), 105 | true <- 106 | Storex.Store in (module.module_info(:attributes) 107 | |> Keyword.get_values(:behaviour) 108 | |> List.flatten()) do 109 | {:ok, module} 110 | else 111 | false -> {:error, :not_store} 112 | _ -> {:error, :not_exists} 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/storex/store.ex: -------------------------------------------------------------------------------- 1 | defmodule Storex.Store do 2 | @doc """ 3 | Called when store session starts. 4 | """ 5 | @callback init(session_id :: binary(), params :: %{binary() => any()}) :: 6 | {:ok, state :: any()} 7 | | {:ok, state :: any(), key :: binary()} 8 | | {:error, reason :: binary()} 9 | 10 | @callback mutation( 11 | name :: binary(), 12 | data :: any(), 13 | session_id :: binary(), 14 | params :: %{binary() => any()}, 15 | state :: any() 16 | ) :: 17 | {:reply, message :: any(), state :: any()} 18 | | {:noreply, state :: any()} 19 | | {:error, state :: any()} 20 | @doc """ 21 | Called when store session ends. 22 | """ 23 | @callback terminate(session_id :: binary(), params :: %{binary() => any()}, state :: any()) :: any() 24 | @optional_callbacks terminate: 3 25 | 26 | defmacro __using__(_opts) do 27 | quote do 28 | @behaviour Storex.Store 29 | 30 | @before_compile Storex.Store 31 | end 32 | end 33 | 34 | defmacro __before_compile__(env) do 35 | quote do 36 | defmodule Server do 37 | use GenServer 38 | 39 | @store unquote(env.module) 40 | 41 | def init({session, init_state, params}) do 42 | {:ok, 43 | %{ 44 | state: init_state, 45 | session: session, 46 | params: params 47 | }} 48 | end 49 | 50 | def start_link([], session: session, store: store, params: params) do 51 | opts = [name: Storex.Supervisor.name(session, store)] 52 | 53 | with {:ok, state, key} <- init_store(session, params), 54 | {:ok, pid} <- GenServer.start_link(Server, {session, state, params}, opts) do 55 | {:ok, pid, %{session: session, key: key}} 56 | else 57 | {:error, reason} -> {:error, reason} 58 | end 59 | end 60 | 61 | def handle_cast(:session_ended, state) do 62 | if :erlang.function_exported(@store, :terminate, 3) do 63 | Kernel.apply(@store, :terminate, [state.session, state.params, state.state]) 64 | end 65 | 66 | {:stop, :normal, state} 67 | end 68 | 69 | def handle_call({name, data}, _, state) do 70 | try do 71 | Kernel.apply(@store, :mutation, [name, data, state.session, state.params, state.state]) 72 | |> case do 73 | {:reply, message, result} -> 74 | diff = Storex.Diff.check(state.state, result) 75 | state = Map.put(state, :state, result) 76 | {:reply, {:ok, message, diff}, state} 77 | 78 | {:noreply, result} -> 79 | diff = Storex.Diff.check(state.state, result) 80 | state = Map.put(state, :state, result) 81 | {:reply, {:ok, diff}, state} 82 | 83 | {:error, error} -> 84 | {:reply, {:error, error}, state} 85 | 86 | _ -> 87 | {:reply, 88 | {:error, 89 | "Return value of mutation should be {:reply, message, state}, {:noreply, state} or {:error, error}"}, 90 | state} 91 | end 92 | rescue 93 | e in FunctionClauseError -> 94 | {:reply, 95 | {:error, 96 | "No mutation matching #{inspect(name)} with data #{inspect(data)} in store #{inspect(@store)}"}, 97 | state} 98 | end 99 | end 100 | 101 | def handle_call(call, _, _state) do 102 | raise "Not handled call: #{inspect(call)}" 103 | end 104 | 105 | defp init_store(session, params) do 106 | @store.init(session, params) 107 | |> case do 108 | {:ok, state} -> 109 | {:ok, state, nil} 110 | 111 | {:ok, state, key} -> 112 | {:ok, state, key} 113 | 114 | {:error, reason} -> 115 | {:error, reason} 116 | 117 | _ -> 118 | raise "Return value of store init should be {:ok, state}, {:ok, state, key} or {:error, reason}" 119 | end 120 | end 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/storex/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Storex.Supervisor do 2 | @moduledoc false 3 | 4 | use DynamicSupervisor 5 | 6 | def start_link(_) do 7 | DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) 8 | end 9 | 10 | @impl true 11 | def init(initial_arg) do 12 | DynamicSupervisor.init( 13 | strategy: :one_for_one, 14 | extra_arguments: [initial_arg] 15 | ) 16 | end 17 | 18 | def name(session, store) do 19 | String.to_atom("#{session}_#{store}") 20 | end 21 | 22 | def add_store(store, session, session_pid, params \\ %{}) do 23 | Storex.Registry.get_store(store, session) 24 | |> case do 25 | :undefined -> 26 | store_server = Module.concat([store, "Server"]) 27 | 28 | spec = %{ 29 | id: store_server, 30 | start: {store_server, :start_link, [[session: session, store: store, params: params]]}, 31 | restart: :transient 32 | } 33 | 34 | DynamicSupervisor.start_child(__MODULE__, spec) 35 | |> case do 36 | {:ok, store_pid, %{key: key}} -> 37 | Storex.Registry.register_store(store, store_pid, session, session_pid, key) 38 | {:ok, key} 39 | 40 | {:error, error} -> 41 | {:error, error} 42 | end 43 | 44 | {_, _, _, _, key} -> 45 | {:ok, key} 46 | end 47 | end 48 | 49 | def get_store_state(session, store) do 50 | Storex.Registry.get_store_pid(store, session) 51 | |> :sys.get_state() 52 | |> Map.get(:state) 53 | end 54 | 55 | def mutate_store(session, store, name, data) do 56 | Storex.Registry.get_store_pid(store, session) 57 | |> GenServer.call({name, data}) 58 | end 59 | 60 | def remove_store(session, store) do 61 | Storex.Registry.get_store_pid(store, session) 62 | |> GenServer.cast(:session_ended) 63 | 64 | Storex.Registry.unregister_store(store, session) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Storex.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.6.1" 5 | 6 | def project do 7 | [ 8 | app: :storex, 9 | version: @version, 10 | elixir: "~> 1.10", 11 | elixirc_paths: elixirc_paths(Mix.env()), 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | 15 | # Docs 16 | name: "storex", 17 | source_url: "https://github.com/nerdslabs/storex", 18 | homepage_url: "http://nerdslabs.co", 19 | docs: docs(), 20 | description: description(), 21 | package: package(), 22 | aliases: [ 23 | test: "test --no-start" 24 | ] 25 | ] 26 | end 27 | 28 | defp elixirc_paths(:test), do: ["lib", "test/fixtures", "test/storex", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | def application do 32 | [ 33 | mod: {Storex, []} 34 | ] 35 | end 36 | 37 | defp docs do 38 | [ 39 | source_ref: "v#{@version}", 40 | main: "readme", 41 | extras: ["README.md"] 42 | ] 43 | end 44 | 45 | defp deps do 46 | [ 47 | {:websock_adapter, "~> 0.5.6"}, 48 | {:plug, "~> 1.15"}, 49 | {:nanoid, "~> 2.0"}, 50 | {:jason, "~> 1.4"}, 51 | 52 | # Docs 53 | {:ex_doc, "~> 0.29", only: :dev, runtime: false}, 54 | {:dialyxir, "~> 1.4", only: [:dev], runtime: false}, 55 | 56 | # Tests 57 | {:ssl_verify_fun, "~> 1.1", only: :test, manager: :rebar3, override: true}, 58 | {:wallaby, "~> 0.30.0", runtime: false, only: :test}, 59 | {:cowboy, "~> 2.9", only: :test}, 60 | {:bandit, "~> 1.4", only: :test}, 61 | {:plug_cowboy, "~> 2.0", only: :test}, 62 | {:local_cluster, "~> 1.2", only: [:test]} 63 | ] 64 | end 65 | 66 | defp description() do 67 | "Frontend store managed in backend." 68 | end 69 | 70 | defp package() do 71 | [ 72 | name: "storex", 73 | files: ["lib", "priv", "mix.exs", "package.json", "README*", "LICENSE*", ".formatter.exs"], 74 | licenses: ["Apache-2.0"], 75 | links: %{"GitHub" => "https://github.com/nerdslabs/storex"} 76 | ] 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "1.5.5", "df28f1c41f745401fe9e85a6882033f5f3442ab6d30c8a2948554062a4ab56e0", [:mix], [{:hpax, "~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f21579a29ea4bc08440343b2b5f16f7cddf2fea5725d31b72cf973ec729079e1"}, 3 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, 4 | "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, 5 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 6 | "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, 7 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 8 | "earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm"}, 9 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 10 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 11 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 12 | "global_flags": {:hex, :global_flags, "1.0.0", "ee6b864979a1fb38d1fbc67838565644baf632212bce864adca21042df036433", [:rebar3], [], "hexpm", "85d944cecd0f8f96b20ce70b5b16ebccedfcd25e744376b131e89ce61ba93176"}, 13 | "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, 14 | "hpax": {:hex, :hpax, "0.2.0", "5a58219adcb75977b2edce5eb22051de9362f08236220c9e859a47111c194ff5", [:mix], [], "hexpm", "bea06558cdae85bed075e6c036993d43cd54d447f76d8190a8db0dc5893fa2f1"}, 15 | "httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"}, 16 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 17 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 18 | "local_cluster": {:hex, :local_cluster, "1.2.1", "8eab3b8a387680f0872eacfb1a8bd5a91cb1d4d61256eec6a655b07ac7030c73", [:mix], [{:global_flags, "~> 1.0", [hex: :global_flags, repo: "hexpm", optional: false]}], "hexpm", "aae80c9bc92c911cb0be085fdeea2a9f5b88f81b6bec2ff1fec244bb0acc232c"}, 19 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 20 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 21 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 22 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 23 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 24 | "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, 25 | "nanoid": {:hex, :nanoid, "2.1.0", "d192a5bf1d774258bc49762b480fca0e3128178fa6d35a464af2a738526607fd", [:mix], [], "hexpm", "ebc7a342d02d213534a7f93a091d569b9fea7f26fcd3a638dc655060fc1f76ac"}, 26 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 27 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 28 | "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, 29 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.1", "87677ffe3b765bc96a89be7960f81703223fe2e21efa42c125fcd0127dd9d6b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "02dbd5f9ab571b864ae39418db7811618506256f6d13b4a45037e5fe78dc5de3"}, 30 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 31 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 32 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 33 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 34 | "tesla": {:hex, :tesla, "1.11.2", "24707ac48b52f72f88fc05d242b1c59a85d1ee6f16f19c312d7d3419665c9cd5", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "c549cd03aec6a7196a641689dd378b799e635eb393f689b4bd756f750c7a4014"}, 35 | "thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"}, 36 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 37 | "wallaby": {:hex, :wallaby, "0.30.9", "51d60682092c3c428c63b656b818e2258202b9f9a31ec37230659647ae20325b", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:httpoison, "~> 0.12 or ~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, ">= 3.0.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:web_driver_client, "~> 0.2.0", [hex: :web_driver_client, repo: "hexpm", optional: false]}], "hexpm", "62e3ccb89068b231b50ed046219022020516d44f443eebef93a19db4be95b808"}, 38 | "web_driver_client": {:hex, :web_driver_client, "0.2.0", "63b76cd9eb3b0716ec5467a0f8bead73d3d9612e63f7560d21357f03ad86e31a", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "83cc6092bc3e74926d1c8455f0ce927d5d1d36707b74d9a65e38c084aab0350f"}, 39 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 40 | "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"}, 41 | } 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storex", 3 | "version": "0.6.1", 4 | "main": "./priv/static/storex.umd.js", 5 | "module": "./priv/static/storex.esm.js", 6 | "types": "./priv/static/storex.d.ts", 7 | "exports": { 8 | ".": { 9 | "import": "./priv/static/storex.esm.js", 10 | "require": "./priv/static/storex.cjs.ts", 11 | "types": "./priv/static/storex.d.ts" 12 | }, 13 | "./package.json": "./package.json" 14 | }, 15 | "author": "NERDSLABS ", 16 | "license": "Apache-2.0" 17 | } 18 | -------------------------------------------------------------------------------- /priv/static/storex.cjs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { value: true }); 4 | 5 | /****************************************************************************** 6 | Copyright (c) Microsoft Corporation. 7 | 8 | Permission to use, copy, modify, and/or distribute this software for any 9 | purpose with or without fee is hereby granted. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 12 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 13 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 14 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 15 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 16 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 17 | PERFORMANCE OF THIS SOFTWARE. 18 | ***************************************************************************** */ 19 | /* global Reflect, Promise, SuppressedError, Symbol */ 20 | 21 | 22 | var __assign = function() { 23 | __assign = Object.assign || function __assign(t) { 24 | for (var s, i = 1, n = arguments.length; i < n; i++) { 25 | s = arguments[i]; 26 | for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; 27 | } 28 | return t; 29 | }; 30 | return __assign.apply(this, arguments); 31 | }; 32 | 33 | function __awaiter(thisArg, _arguments, P, generator) { 34 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 35 | return new (P || (P = Promise))(function (resolve, reject) { 36 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 37 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 38 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 39 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 40 | }); 41 | } 42 | 43 | function __generator(thisArg, body) { 44 | var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; 45 | return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; 46 | function verb(n) { return function (v) { return step([n, v]); }; } 47 | function step(op) { 48 | if (f) throw new TypeError("Generator is already executing."); 49 | while (g && (g = 0, op[0] && (_ = 0)), _) try { 50 | if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; 51 | if (y = 0, t) op = [op[0] & 2, t.value]; 52 | switch (op[0]) { 53 | case 0: case 1: t = op; break; 54 | case 4: _.label++; return { value: op[1], done: false }; 55 | case 5: _.label++; y = op[1]; op = [0]; continue; 56 | case 7: op = _.ops.pop(); _.trys.pop(); continue; 57 | default: 58 | if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } 59 | if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } 60 | if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } 61 | if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } 62 | if (t[2]) _.ops.pop(); 63 | _.trys.pop(); continue; 64 | } 65 | op = body.call(thisArg, _); 66 | } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } 67 | if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; 68 | } 69 | } 70 | 71 | typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { 72 | var e = new Error(message); 73 | return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; 74 | }; 75 | 76 | var httpConnector = function (_a) { 77 | var address = _a.address; 78 | var connectListeners = []; 79 | var fullAddress; 80 | if (typeof address !== 'undefined') { 81 | fullAddress = address; 82 | } 83 | else if (typeof window !== 'undefined') { 84 | fullAddress = "".concat(window.location.protocol, "//").concat(window.location.host, "/storex"); 85 | } 86 | else { 87 | throw "Address is required in non-browser environment"; 88 | } 89 | var join = function (store, params) { 90 | var queryParams = new URLSearchParams({ 91 | store: store, 92 | params: JSON.stringify(params) 93 | }).toString(); 94 | return new Promise(function (resolve, reject) { 95 | fetch("".concat(fullAddress, "?").concat(queryParams), { 96 | method: 'GET', 97 | headers: { 98 | 'Content-Type': 'application/json' 99 | } 100 | }) 101 | .then(function (response) { return __awaiter(void 0, void 0, void 0, function () { 102 | var _a; 103 | return __generator(this, function (_b) { 104 | switch (_b.label) { 105 | case 0: 106 | if (!!response.ok) return [3 /*break*/, 2]; 107 | _a = reject; 108 | return [4 /*yield*/, response.json()]; 109 | case 1: 110 | _a.apply(void 0, [_b.sent()]); 111 | _b.label = 2; 112 | case 2: return [2 /*return*/, response.json()]; 113 | } 114 | }); 115 | }); }) 116 | .then(resolve) 117 | .catch(reject); 118 | }); 119 | }; 120 | var connect = function () { 121 | connectListeners.forEach(function (listener) { return listener(); }); 122 | }; 123 | var mutate = function (store, session, name, data) { 124 | console.warn('Mutation is not supported in the HTTP version'); 125 | return Promise.resolve({ type: 'mutation', store: store, session: session, diff: [] }); 126 | }; 127 | return { 128 | connect: connect, 129 | join: join, 130 | mutate: mutate, 131 | onConnected: function (listener) { 132 | connectListeners.push(listener); 133 | }, 134 | onDisconnected: function (listener) { 135 | }, 136 | onMutated: function (listener) { 137 | } 138 | }; 139 | }; 140 | 141 | var generateRequestId = function () { 142 | var minCharCode = 48; 143 | var maxCharCode = 122; 144 | return Array.from({ length: 10 }, function () { 145 | return String.fromCharCode(Math.floor(Math.random() * (maxCharCode - minCharCode + 1)) + minCharCode); 146 | }).join(''); 147 | }; 148 | var socketConnector = function (_a) { 149 | var address = _a.address; 150 | var socket = null; 151 | var keeper = null; 152 | var requests = {}; 153 | var connectListeners = []; 154 | var mutateListeners = []; 155 | var disconnectListeners = []; 156 | function isConnected() { 157 | return socket !== undefined && (socket === null || socket === void 0 ? void 0 : socket.readyState) === WebSocket.OPEN; 158 | } 159 | var connect = function () { 160 | if (!isConnected() && socket === null) { 161 | var fullAddress = address || location.host + '/storex'; 162 | var protocol = location.protocol === 'https:' ? 'wss://' : 'ws://'; 163 | socket = new WebSocket(protocol + fullAddress); 164 | socket.binaryType = 'arraybuffer'; 165 | socket.onopen = onOpen; 166 | socket.onclose = onClose; 167 | socket.onmessage = onMessage; 168 | } 169 | }; 170 | var onOpen = function (event) { 171 | if ((socket === null || socket === void 0 ? void 0 : socket.readyState) === WebSocket.OPEN) { 172 | connectListeners.forEach(function (listener) { return listener(); }); 173 | keeper = setInterval(function () { return send({ type: 'ping' }); }, 30000); 174 | } 175 | else { 176 | setTimeout(function () { return onOpen(); }, 100); 177 | } 178 | }; 179 | var onMessage = function (event) { 180 | var data = JSON.parse(event.data); 181 | var request = requests[data.request]; 182 | if (request !== undefined) { 183 | var resolve = request[0], reject = request[1]; 184 | data.type === 'error' ? reject(data) : resolve(data); 185 | } 186 | else if (data.type === 'mutation') { 187 | mutateListeners.forEach(function (listener) { return listener(data.store, data.session, data.diff); }); 188 | } 189 | }; 190 | var onClose = function (event) { 191 | socket = null; 192 | var code = event.code; 193 | var reason = event.reason; 194 | if (code >= 4000) { 195 | console.error('[storex]', reason); 196 | } 197 | else if ([1000, 1005, 1006].includes(code)) { 198 | connect(); 199 | } 200 | disconnectListeners.forEach(function (listener) { return listener(event); }); 201 | if (keeper !== null) { 202 | clearInterval(keeper); 203 | } 204 | }; 205 | var send = function (data) { 206 | return new Promise(function (resolve, reject) { 207 | var request = generateRequestId(); 208 | var payload = __assign(__assign({}, data), { request: request }); 209 | socket === null || socket === void 0 ? void 0 : socket.send(JSON.stringify(payload)); 210 | requests[request] = [resolve, reject]; 211 | }); 212 | }; 213 | var onConnected = function (listener) { 214 | if (isConnected()) { 215 | listener(); 216 | } 217 | connectListeners.push(listener); 218 | }; 219 | var join = function (store, params) { 220 | return send({ 221 | type: 'join', 222 | store: store, 223 | data: params, 224 | }); 225 | }; 226 | var mutate = function (store, session, name, data) { 227 | return send({ 228 | type: 'mutation', 229 | store: store, 230 | session: session, 231 | data: { 232 | name: name, 233 | data: data, 234 | }, 235 | }); 236 | }; 237 | return { 238 | connect: connect, 239 | join: join, 240 | mutate: mutate, 241 | onConnected: onConnected, 242 | onDisconnected: function (listener) { return disconnectListeners.push(listener); }, 243 | onMutated: function (listener) { return mutateListeners.push(listener); }, 244 | }; 245 | }; 246 | 247 | function set(object, path, value) { 248 | if (path.length > 0) { 249 | var index = path.pop(); 250 | var parent = path.reduce(function (o, i) { return o[i]; }, object); 251 | parent[index] = value; 252 | return object; 253 | } 254 | else { 255 | return value; 256 | } 257 | } 258 | function remove(object, path) { 259 | var index = path.pop(); 260 | var parent = path.reduce(function (o, i) { return o[i]; }, object); 261 | if (Array.isArray(parent)) { 262 | parent.splice(index, 1); 263 | } 264 | else { 265 | delete parent[index]; 266 | } 267 | return object; 268 | } 269 | function patch(source, changes) { 270 | for (var _i = 0, changes_1 = changes; _i < changes_1.length; _i++) { 271 | var change = changes_1[_i]; 272 | if (change.a === 'u') { 273 | source = set(source, change.p, change.t); 274 | } 275 | else if (change.a === 'd') { 276 | source = remove(source, change.p); 277 | } 278 | else if (change.a === 'i') { 279 | source = set(source, change.p, change.t); 280 | } 281 | } 282 | return source; 283 | } 284 | 285 | var bindStore = function (connector, config) { 286 | var state; 287 | var session = null; 288 | var messageListeners = new Set(); 289 | var connectedListeners = new Set(); 290 | var errorListeners = new Set(); 291 | var disconnectedListeners = new Set(); 292 | var setState = function (newState) { 293 | state = newState; 294 | messageListeners.forEach(function (listener) { return listener(state); }); 295 | }; 296 | var handleConnected = function () { 297 | connector.join(config.store, config.params).then(function (response) { 298 | session = response.session; 299 | setState(response.data); 300 | connectedListeners.forEach(function (listener) { return listener(); }); 301 | }, function (error) { 302 | errorListeners.forEach(function (listener) { return listener(error.error); }); 303 | }); 304 | }; 305 | var handleMutation = function (store, mutationSession, diff) { 306 | if (store === config.store && mutationSession === session) { 307 | setState(patch(state, diff)); 308 | } 309 | }; 310 | var commit = function (name) { 311 | var data = []; 312 | for (var _i = 1; _i < arguments.length; _i++) { 313 | data[_i - 1] = arguments[_i]; 314 | } 315 | return new Promise(function (resolve, reject) { 316 | connector.mutate(config.store, session, name, data).then(function (response) { 317 | setState(patch(state, response.diff)); 318 | resolve(response.message); 319 | }, function (error) { return reject(error.error); }); 320 | }); 321 | }; 322 | connector.onConnected(handleConnected); 323 | connector.onDisconnected(function (event) { 324 | disconnectedListeners.forEach(function (listener) { return listener(event); }); 325 | }); 326 | connector.onMutated(handleMutation); 327 | connector.connect(); 328 | return { 329 | commit: commit, 330 | get state() { 331 | return state; 332 | }, 333 | get session() { 334 | return session; 335 | }, 336 | subscribe: function (listener) { 337 | messageListeners.add(listener); 338 | if (state !== undefined) { 339 | listener(state); 340 | } 341 | return function () { return messageListeners.delete(listener); }; 342 | }, 343 | onConnected: function (listener) { 344 | connectedListeners.add(listener); 345 | return function () { return connectedListeners.delete(listener); }; 346 | }, 347 | onError: function (listener) { 348 | errorListeners.add(listener); 349 | return function () { return errorListeners.delete(listener); }; 350 | }, 351 | onDisconnected: function (listener) { 352 | disconnectedListeners.add(listener); 353 | return function () { return disconnectedListeners.delete(listener); }; 354 | }, 355 | }; 356 | }; 357 | var prepare = function (params, connector) { 358 | var useStorex = function (config) { 359 | return bindStore(connector, { 360 | store: config.store, 361 | params: __assign(__assign({}, config.params), params), 362 | }); 363 | }; 364 | return { 365 | useStorex: useStorex, 366 | }; 367 | }; 368 | var useStorex = function () { 369 | var connector = socketConnector({}); 370 | return function (config) { return bindStore(connector, config); }; 371 | }; 372 | var storex = useStorex(); 373 | 374 | exports.default = storex; 375 | exports.httpConnector = httpConnector; 376 | exports.prepare = prepare; 377 | exports.socketConnector = socketConnector; 378 | -------------------------------------------------------------------------------- /priv/static/storex.d.ts: -------------------------------------------------------------------------------- 1 | type Change = { 2 | a: 'u' | 'd' | 'i'; 3 | p: any[]; 4 | t: unknown; 5 | }; 6 | 7 | type ResponseJoin = { 8 | type: "join"; 9 | store: string; 10 | session: string; 11 | data: T; 12 | }; 13 | type ResponseMutation = { 14 | type: "mutation"; 15 | store: string; 16 | session: string; 17 | message?: T; 18 | diff: Change[]; 19 | }; 20 | type Connector = { 21 | onConnected: (listener: () => void) => void; 22 | onMutated: (listener: (store: string, session: string, diff: Change[]) => void) => void; 23 | onDisconnected: (listener: (event: CloseEvent) => void) => void; 24 | connect: () => void; 25 | join: (store: string, params: unknown) => Promise>; 26 | mutate: (store: string, session: string, name: string, data: unknown) => Promise>; 27 | }; 28 | type ConnectorBuilder = (options: { 29 | address?: string; 30 | }) => Connector; 31 | 32 | declare const httpConnector: ConnectorBuilder; 33 | 34 | declare const socketConnector: ConnectorBuilder; 35 | 36 | type Params = { 37 | [key: string]: any; 38 | }; 39 | type StoreConfig = { 40 | store: string; 41 | params: Params; 42 | }; 43 | declare const prepare: (params: Params, connector: Connector) => { 44 | useStorex: (config: StoreConfig) => { 45 | commit: (name: string, ...data: any) => Promise; 46 | readonly state: T; 47 | readonly session: string; 48 | subscribe: (listener: (state: T) => void) => () => boolean; 49 | onConnected: (listener: () => void) => () => boolean; 50 | onError: (listener: (error: unknown) => void) => () => boolean; 51 | onDisconnected: (listener: (event: CloseEvent) => void) => () => boolean; 52 | }; 53 | }; 54 | declare const _default: (config: StoreConfig) => { 55 | commit: (name: string, ...data: any) => Promise; 56 | readonly state: T; 57 | readonly session: string; 58 | subscribe: (listener: (state: T) => void) => () => boolean; 59 | onConnected: (listener: () => void) => () => boolean; 60 | onError: (listener: (error: unknown) => void) => () => boolean; 61 | onDisconnected: (listener: (event: CloseEvent) => void) => () => boolean; 62 | }; 63 | 64 | export { _default as default, httpConnector, prepare, socketConnector }; 65 | -------------------------------------------------------------------------------- /priv/static/storex.esm.js: -------------------------------------------------------------------------------- 1 | /****************************************************************************** 2 | Copyright (c) Microsoft Corporation. 3 | 4 | Permission to use, copy, modify, and/or distribute this software for any 5 | purpose with or without fee is hereby granted. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 9 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | PERFORMANCE OF THIS SOFTWARE. 14 | ***************************************************************************** */ 15 | /* global Reflect, Promise, SuppressedError, Symbol */ 16 | 17 | 18 | var __assign = function() { 19 | __assign = Object.assign || function __assign(t) { 20 | for (var s, i = 1, n = arguments.length; i < n; i++) { 21 | s = arguments[i]; 22 | for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; 23 | } 24 | return t; 25 | }; 26 | return __assign.apply(this, arguments); 27 | }; 28 | 29 | function __awaiter(thisArg, _arguments, P, generator) { 30 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 31 | return new (P || (P = Promise))(function (resolve, reject) { 32 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 33 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 34 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 35 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 36 | }); 37 | } 38 | 39 | function __generator(thisArg, body) { 40 | var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; 41 | return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; 42 | function verb(n) { return function (v) { return step([n, v]); }; } 43 | function step(op) { 44 | if (f) throw new TypeError("Generator is already executing."); 45 | while (g && (g = 0, op[0] && (_ = 0)), _) try { 46 | if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; 47 | if (y = 0, t) op = [op[0] & 2, t.value]; 48 | switch (op[0]) { 49 | case 0: case 1: t = op; break; 50 | case 4: _.label++; return { value: op[1], done: false }; 51 | case 5: _.label++; y = op[1]; op = [0]; continue; 52 | case 7: op = _.ops.pop(); _.trys.pop(); continue; 53 | default: 54 | if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } 55 | if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } 56 | if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } 57 | if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } 58 | if (t[2]) _.ops.pop(); 59 | _.trys.pop(); continue; 60 | } 61 | op = body.call(thisArg, _); 62 | } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } 63 | if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; 64 | } 65 | } 66 | 67 | typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { 68 | var e = new Error(message); 69 | return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; 70 | }; 71 | 72 | var httpConnector = function (_a) { 73 | var address = _a.address; 74 | var connectListeners = []; 75 | var fullAddress; 76 | if (typeof address !== 'undefined') { 77 | fullAddress = address; 78 | } 79 | else if (typeof window !== 'undefined') { 80 | fullAddress = "".concat(window.location.protocol, "//").concat(window.location.host, "/storex"); 81 | } 82 | else { 83 | throw "Address is required in non-browser environment"; 84 | } 85 | var join = function (store, params) { 86 | var queryParams = new URLSearchParams({ 87 | store: store, 88 | params: JSON.stringify(params) 89 | }).toString(); 90 | return new Promise(function (resolve, reject) { 91 | fetch("".concat(fullAddress, "?").concat(queryParams), { 92 | method: 'GET', 93 | headers: { 94 | 'Content-Type': 'application/json' 95 | } 96 | }) 97 | .then(function (response) { return __awaiter(void 0, void 0, void 0, function () { 98 | var _a; 99 | return __generator(this, function (_b) { 100 | switch (_b.label) { 101 | case 0: 102 | if (!!response.ok) return [3 /*break*/, 2]; 103 | _a = reject; 104 | return [4 /*yield*/, response.json()]; 105 | case 1: 106 | _a.apply(void 0, [_b.sent()]); 107 | _b.label = 2; 108 | case 2: return [2 /*return*/, response.json()]; 109 | } 110 | }); 111 | }); }) 112 | .then(resolve) 113 | .catch(reject); 114 | }); 115 | }; 116 | var connect = function () { 117 | connectListeners.forEach(function (listener) { return listener(); }); 118 | }; 119 | var mutate = function (store, session, name, data) { 120 | console.warn('Mutation is not supported in the HTTP version'); 121 | return Promise.resolve({ type: 'mutation', store: store, session: session, diff: [] }); 122 | }; 123 | return { 124 | connect: connect, 125 | join: join, 126 | mutate: mutate, 127 | onConnected: function (listener) { 128 | connectListeners.push(listener); 129 | }, 130 | onDisconnected: function (listener) { 131 | }, 132 | onMutated: function (listener) { 133 | } 134 | }; 135 | }; 136 | 137 | var generateRequestId = function () { 138 | var minCharCode = 48; 139 | var maxCharCode = 122; 140 | return Array.from({ length: 10 }, function () { 141 | return String.fromCharCode(Math.floor(Math.random() * (maxCharCode - minCharCode + 1)) + minCharCode); 142 | }).join(''); 143 | }; 144 | var socketConnector = function (_a) { 145 | var address = _a.address; 146 | var socket = null; 147 | var keeper = null; 148 | var requests = {}; 149 | var connectListeners = []; 150 | var mutateListeners = []; 151 | var disconnectListeners = []; 152 | function isConnected() { 153 | return socket !== undefined && (socket === null || socket === void 0 ? void 0 : socket.readyState) === WebSocket.OPEN; 154 | } 155 | var connect = function () { 156 | if (!isConnected() && socket === null) { 157 | var fullAddress = address || location.host + '/storex'; 158 | var protocol = location.protocol === 'https:' ? 'wss://' : 'ws://'; 159 | socket = new WebSocket(protocol + fullAddress); 160 | socket.binaryType = 'arraybuffer'; 161 | socket.onopen = onOpen; 162 | socket.onclose = onClose; 163 | socket.onmessage = onMessage; 164 | } 165 | }; 166 | var onOpen = function (event) { 167 | if ((socket === null || socket === void 0 ? void 0 : socket.readyState) === WebSocket.OPEN) { 168 | connectListeners.forEach(function (listener) { return listener(); }); 169 | keeper = setInterval(function () { return send({ type: 'ping' }); }, 30000); 170 | } 171 | else { 172 | setTimeout(function () { return onOpen(); }, 100); 173 | } 174 | }; 175 | var onMessage = function (event) { 176 | var data = JSON.parse(event.data); 177 | var request = requests[data.request]; 178 | if (request !== undefined) { 179 | var resolve = request[0], reject = request[1]; 180 | data.type === 'error' ? reject(data) : resolve(data); 181 | } 182 | else if (data.type === 'mutation') { 183 | mutateListeners.forEach(function (listener) { return listener(data.store, data.session, data.diff); }); 184 | } 185 | }; 186 | var onClose = function (event) { 187 | socket = null; 188 | var code = event.code; 189 | var reason = event.reason; 190 | if (code >= 4000) { 191 | console.error('[storex]', reason); 192 | } 193 | else if ([1000, 1005, 1006].includes(code)) { 194 | connect(); 195 | } 196 | disconnectListeners.forEach(function (listener) { return listener(event); }); 197 | if (keeper !== null) { 198 | clearInterval(keeper); 199 | } 200 | }; 201 | var send = function (data) { 202 | return new Promise(function (resolve, reject) { 203 | var request = generateRequestId(); 204 | var payload = __assign(__assign({}, data), { request: request }); 205 | socket === null || socket === void 0 ? void 0 : socket.send(JSON.stringify(payload)); 206 | requests[request] = [resolve, reject]; 207 | }); 208 | }; 209 | var onConnected = function (listener) { 210 | if (isConnected()) { 211 | listener(); 212 | } 213 | connectListeners.push(listener); 214 | }; 215 | var join = function (store, params) { 216 | return send({ 217 | type: 'join', 218 | store: store, 219 | data: params, 220 | }); 221 | }; 222 | var mutate = function (store, session, name, data) { 223 | return send({ 224 | type: 'mutation', 225 | store: store, 226 | session: session, 227 | data: { 228 | name: name, 229 | data: data, 230 | }, 231 | }); 232 | }; 233 | return { 234 | connect: connect, 235 | join: join, 236 | mutate: mutate, 237 | onConnected: onConnected, 238 | onDisconnected: function (listener) { return disconnectListeners.push(listener); }, 239 | onMutated: function (listener) { return mutateListeners.push(listener); }, 240 | }; 241 | }; 242 | 243 | function set(object, path, value) { 244 | if (path.length > 0) { 245 | var index = path.pop(); 246 | var parent = path.reduce(function (o, i) { return o[i]; }, object); 247 | parent[index] = value; 248 | return object; 249 | } 250 | else { 251 | return value; 252 | } 253 | } 254 | function remove(object, path) { 255 | var index = path.pop(); 256 | var parent = path.reduce(function (o, i) { return o[i]; }, object); 257 | if (Array.isArray(parent)) { 258 | parent.splice(index, 1); 259 | } 260 | else { 261 | delete parent[index]; 262 | } 263 | return object; 264 | } 265 | function patch(source, changes) { 266 | for (var _i = 0, changes_1 = changes; _i < changes_1.length; _i++) { 267 | var change = changes_1[_i]; 268 | if (change.a === 'u') { 269 | source = set(source, change.p, change.t); 270 | } 271 | else if (change.a === 'd') { 272 | source = remove(source, change.p); 273 | } 274 | else if (change.a === 'i') { 275 | source = set(source, change.p, change.t); 276 | } 277 | } 278 | return source; 279 | } 280 | 281 | var bindStore = function (connector, config) { 282 | var state; 283 | var session = null; 284 | var messageListeners = new Set(); 285 | var connectedListeners = new Set(); 286 | var errorListeners = new Set(); 287 | var disconnectedListeners = new Set(); 288 | var setState = function (newState) { 289 | state = newState; 290 | messageListeners.forEach(function (listener) { return listener(state); }); 291 | }; 292 | var handleConnected = function () { 293 | connector.join(config.store, config.params).then(function (response) { 294 | session = response.session; 295 | setState(response.data); 296 | connectedListeners.forEach(function (listener) { return listener(); }); 297 | }, function (error) { 298 | errorListeners.forEach(function (listener) { return listener(error.error); }); 299 | }); 300 | }; 301 | var handleMutation = function (store, mutationSession, diff) { 302 | if (store === config.store && mutationSession === session) { 303 | setState(patch(state, diff)); 304 | } 305 | }; 306 | var commit = function (name) { 307 | var data = []; 308 | for (var _i = 1; _i < arguments.length; _i++) { 309 | data[_i - 1] = arguments[_i]; 310 | } 311 | return new Promise(function (resolve, reject) { 312 | connector.mutate(config.store, session, name, data).then(function (response) { 313 | setState(patch(state, response.diff)); 314 | resolve(response.message); 315 | }, function (error) { return reject(error.error); }); 316 | }); 317 | }; 318 | connector.onConnected(handleConnected); 319 | connector.onDisconnected(function (event) { 320 | disconnectedListeners.forEach(function (listener) { return listener(event); }); 321 | }); 322 | connector.onMutated(handleMutation); 323 | connector.connect(); 324 | return { 325 | commit: commit, 326 | get state() { 327 | return state; 328 | }, 329 | get session() { 330 | return session; 331 | }, 332 | subscribe: function (listener) { 333 | messageListeners.add(listener); 334 | if (state !== undefined) { 335 | listener(state); 336 | } 337 | return function () { return messageListeners.delete(listener); }; 338 | }, 339 | onConnected: function (listener) { 340 | connectedListeners.add(listener); 341 | return function () { return connectedListeners.delete(listener); }; 342 | }, 343 | onError: function (listener) { 344 | errorListeners.add(listener); 345 | return function () { return errorListeners.delete(listener); }; 346 | }, 347 | onDisconnected: function (listener) { 348 | disconnectedListeners.add(listener); 349 | return function () { return disconnectedListeners.delete(listener); }; 350 | }, 351 | }; 352 | }; 353 | var prepare = function (params, connector) { 354 | var useStorex = function (config) { 355 | return bindStore(connector, { 356 | store: config.store, 357 | params: __assign(__assign({}, config.params), params), 358 | }); 359 | }; 360 | return { 361 | useStorex: useStorex, 362 | }; 363 | }; 364 | var useStorex = function () { 365 | var connector = socketConnector({}); 366 | return function (config) { return bindStore(connector, config); }; 367 | }; 368 | var storex = useStorex(); 369 | 370 | export { storex as default, httpConnector, prepare, socketConnector }; 371 | -------------------------------------------------------------------------------- /priv/static/storex.umd.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : 3 | typeof define === 'function' && define.amd ? define(['exports'], factory) : 4 | (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.useStorex = {})); 5 | })(this, (function (exports) { 'use strict'; 6 | 7 | /****************************************************************************** 8 | Copyright (c) Microsoft Corporation. 9 | 10 | Permission to use, copy, modify, and/or distribute this software for any 11 | purpose with or without fee is hereby granted. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 14 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 15 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 16 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 17 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 18 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 19 | PERFORMANCE OF THIS SOFTWARE. 20 | ***************************************************************************** */ 21 | /* global Reflect, Promise, SuppressedError, Symbol */ 22 | 23 | 24 | var __assign = function() { 25 | __assign = Object.assign || function __assign(t) { 26 | for (var s, i = 1, n = arguments.length; i < n; i++) { 27 | s = arguments[i]; 28 | for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; 29 | } 30 | return t; 31 | }; 32 | return __assign.apply(this, arguments); 33 | }; 34 | 35 | function __awaiter(thisArg, _arguments, P, generator) { 36 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 37 | return new (P || (P = Promise))(function (resolve, reject) { 38 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 39 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 40 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 41 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 42 | }); 43 | } 44 | 45 | function __generator(thisArg, body) { 46 | var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; 47 | return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; 48 | function verb(n) { return function (v) { return step([n, v]); }; } 49 | function step(op) { 50 | if (f) throw new TypeError("Generator is already executing."); 51 | while (g && (g = 0, op[0] && (_ = 0)), _) try { 52 | if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; 53 | if (y = 0, t) op = [op[0] & 2, t.value]; 54 | switch (op[0]) { 55 | case 0: case 1: t = op; break; 56 | case 4: _.label++; return { value: op[1], done: false }; 57 | case 5: _.label++; y = op[1]; op = [0]; continue; 58 | case 7: op = _.ops.pop(); _.trys.pop(); continue; 59 | default: 60 | if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } 61 | if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } 62 | if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } 63 | if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } 64 | if (t[2]) _.ops.pop(); 65 | _.trys.pop(); continue; 66 | } 67 | op = body.call(thisArg, _); 68 | } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } 69 | if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; 70 | } 71 | } 72 | 73 | typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { 74 | var e = new Error(message); 75 | return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; 76 | }; 77 | 78 | var httpConnector = function (_a) { 79 | var address = _a.address; 80 | var connectListeners = []; 81 | var fullAddress; 82 | if (typeof address !== 'undefined') { 83 | fullAddress = address; 84 | } 85 | else if (typeof window !== 'undefined') { 86 | fullAddress = "".concat(window.location.protocol, "//").concat(window.location.host, "/storex"); 87 | } 88 | else { 89 | throw "Address is required in non-browser environment"; 90 | } 91 | var join = function (store, params) { 92 | var queryParams = new URLSearchParams({ 93 | store: store, 94 | params: JSON.stringify(params) 95 | }).toString(); 96 | return new Promise(function (resolve, reject) { 97 | fetch("".concat(fullAddress, "?").concat(queryParams), { 98 | method: 'GET', 99 | headers: { 100 | 'Content-Type': 'application/json' 101 | } 102 | }) 103 | .then(function (response) { return __awaiter(void 0, void 0, void 0, function () { 104 | var _a; 105 | return __generator(this, function (_b) { 106 | switch (_b.label) { 107 | case 0: 108 | if (!!response.ok) return [3 /*break*/, 2]; 109 | _a = reject; 110 | return [4 /*yield*/, response.json()]; 111 | case 1: 112 | _a.apply(void 0, [_b.sent()]); 113 | _b.label = 2; 114 | case 2: return [2 /*return*/, response.json()]; 115 | } 116 | }); 117 | }); }) 118 | .then(resolve) 119 | .catch(reject); 120 | }); 121 | }; 122 | var connect = function () { 123 | connectListeners.forEach(function (listener) { return listener(); }); 124 | }; 125 | var mutate = function (store, session, name, data) { 126 | console.warn('Mutation is not supported in the HTTP version'); 127 | return Promise.resolve({ type: 'mutation', store: store, session: session, diff: [] }); 128 | }; 129 | return { 130 | connect: connect, 131 | join: join, 132 | mutate: mutate, 133 | onConnected: function (listener) { 134 | connectListeners.push(listener); 135 | }, 136 | onDisconnected: function (listener) { 137 | }, 138 | onMutated: function (listener) { 139 | } 140 | }; 141 | }; 142 | 143 | var generateRequestId = function () { 144 | var minCharCode = 48; 145 | var maxCharCode = 122; 146 | return Array.from({ length: 10 }, function () { 147 | return String.fromCharCode(Math.floor(Math.random() * (maxCharCode - minCharCode + 1)) + minCharCode); 148 | }).join(''); 149 | }; 150 | var socketConnector = function (_a) { 151 | var address = _a.address; 152 | var socket = null; 153 | var keeper = null; 154 | var requests = {}; 155 | var connectListeners = []; 156 | var mutateListeners = []; 157 | var disconnectListeners = []; 158 | function isConnected() { 159 | return socket !== undefined && (socket === null || socket === void 0 ? void 0 : socket.readyState) === WebSocket.OPEN; 160 | } 161 | var connect = function () { 162 | if (!isConnected() && socket === null) { 163 | var fullAddress = address || location.host + '/storex'; 164 | var protocol = location.protocol === 'https:' ? 'wss://' : 'ws://'; 165 | socket = new WebSocket(protocol + fullAddress); 166 | socket.binaryType = 'arraybuffer'; 167 | socket.onopen = onOpen; 168 | socket.onclose = onClose; 169 | socket.onmessage = onMessage; 170 | } 171 | }; 172 | var onOpen = function (event) { 173 | if ((socket === null || socket === void 0 ? void 0 : socket.readyState) === WebSocket.OPEN) { 174 | connectListeners.forEach(function (listener) { return listener(); }); 175 | keeper = setInterval(function () { return send({ type: 'ping' }); }, 30000); 176 | } 177 | else { 178 | setTimeout(function () { return onOpen(); }, 100); 179 | } 180 | }; 181 | var onMessage = function (event) { 182 | var data = JSON.parse(event.data); 183 | var request = requests[data.request]; 184 | if (request !== undefined) { 185 | var resolve = request[0], reject = request[1]; 186 | data.type === 'error' ? reject(data) : resolve(data); 187 | } 188 | else if (data.type === 'mutation') { 189 | mutateListeners.forEach(function (listener) { return listener(data.store, data.session, data.diff); }); 190 | } 191 | }; 192 | var onClose = function (event) { 193 | socket = null; 194 | var code = event.code; 195 | var reason = event.reason; 196 | if (code >= 4000) { 197 | console.error('[storex]', reason); 198 | } 199 | else if ([1000, 1005, 1006].includes(code)) { 200 | connect(); 201 | } 202 | disconnectListeners.forEach(function (listener) { return listener(event); }); 203 | if (keeper !== null) { 204 | clearInterval(keeper); 205 | } 206 | }; 207 | var send = function (data) { 208 | return new Promise(function (resolve, reject) { 209 | var request = generateRequestId(); 210 | var payload = __assign(__assign({}, data), { request: request }); 211 | socket === null || socket === void 0 ? void 0 : socket.send(JSON.stringify(payload)); 212 | requests[request] = [resolve, reject]; 213 | }); 214 | }; 215 | var onConnected = function (listener) { 216 | if (isConnected()) { 217 | listener(); 218 | } 219 | connectListeners.push(listener); 220 | }; 221 | var join = function (store, params) { 222 | return send({ 223 | type: 'join', 224 | store: store, 225 | data: params, 226 | }); 227 | }; 228 | var mutate = function (store, session, name, data) { 229 | return send({ 230 | type: 'mutation', 231 | store: store, 232 | session: session, 233 | data: { 234 | name: name, 235 | data: data, 236 | }, 237 | }); 238 | }; 239 | return { 240 | connect: connect, 241 | join: join, 242 | mutate: mutate, 243 | onConnected: onConnected, 244 | onDisconnected: function (listener) { return disconnectListeners.push(listener); }, 245 | onMutated: function (listener) { return mutateListeners.push(listener); }, 246 | }; 247 | }; 248 | 249 | function set(object, path, value) { 250 | if (path.length > 0) { 251 | var index = path.pop(); 252 | var parent = path.reduce(function (o, i) { return o[i]; }, object); 253 | parent[index] = value; 254 | return object; 255 | } 256 | else { 257 | return value; 258 | } 259 | } 260 | function remove(object, path) { 261 | var index = path.pop(); 262 | var parent = path.reduce(function (o, i) { return o[i]; }, object); 263 | if (Array.isArray(parent)) { 264 | parent.splice(index, 1); 265 | } 266 | else { 267 | delete parent[index]; 268 | } 269 | return object; 270 | } 271 | function patch(source, changes) { 272 | for (var _i = 0, changes_1 = changes; _i < changes_1.length; _i++) { 273 | var change = changes_1[_i]; 274 | if (change.a === 'u') { 275 | source = set(source, change.p, change.t); 276 | } 277 | else if (change.a === 'd') { 278 | source = remove(source, change.p); 279 | } 280 | else if (change.a === 'i') { 281 | source = set(source, change.p, change.t); 282 | } 283 | } 284 | return source; 285 | } 286 | 287 | var bindStore = function (connector, config) { 288 | var state; 289 | var session = null; 290 | var messageListeners = new Set(); 291 | var connectedListeners = new Set(); 292 | var errorListeners = new Set(); 293 | var disconnectedListeners = new Set(); 294 | var setState = function (newState) { 295 | state = newState; 296 | messageListeners.forEach(function (listener) { return listener(state); }); 297 | }; 298 | var handleConnected = function () { 299 | connector.join(config.store, config.params).then(function (response) { 300 | session = response.session; 301 | setState(response.data); 302 | connectedListeners.forEach(function (listener) { return listener(); }); 303 | }, function (error) { 304 | errorListeners.forEach(function (listener) { return listener(error.error); }); 305 | }); 306 | }; 307 | var handleMutation = function (store, mutationSession, diff) { 308 | if (store === config.store && mutationSession === session) { 309 | setState(patch(state, diff)); 310 | } 311 | }; 312 | var commit = function (name) { 313 | var data = []; 314 | for (var _i = 1; _i < arguments.length; _i++) { 315 | data[_i - 1] = arguments[_i]; 316 | } 317 | return new Promise(function (resolve, reject) { 318 | connector.mutate(config.store, session, name, data).then(function (response) { 319 | setState(patch(state, response.diff)); 320 | resolve(response.message); 321 | }, function (error) { return reject(error.error); }); 322 | }); 323 | }; 324 | connector.onConnected(handleConnected); 325 | connector.onDisconnected(function (event) { 326 | disconnectedListeners.forEach(function (listener) { return listener(event); }); 327 | }); 328 | connector.onMutated(handleMutation); 329 | connector.connect(); 330 | return { 331 | commit: commit, 332 | get state() { 333 | return state; 334 | }, 335 | get session() { 336 | return session; 337 | }, 338 | subscribe: function (listener) { 339 | messageListeners.add(listener); 340 | if (state !== undefined) { 341 | listener(state); 342 | } 343 | return function () { return messageListeners.delete(listener); }; 344 | }, 345 | onConnected: function (listener) { 346 | connectedListeners.add(listener); 347 | return function () { return connectedListeners.delete(listener); }; 348 | }, 349 | onError: function (listener) { 350 | errorListeners.add(listener); 351 | return function () { return errorListeners.delete(listener); }; 352 | }, 353 | onDisconnected: function (listener) { 354 | disconnectedListeners.add(listener); 355 | return function () { return disconnectedListeners.delete(listener); }; 356 | }, 357 | }; 358 | }; 359 | var prepare = function (params, connector) { 360 | var useStorex = function (config) { 361 | return bindStore(connector, { 362 | store: config.store, 363 | params: __assign(__assign({}, config.params), params), 364 | }); 365 | }; 366 | return { 367 | useStorex: useStorex, 368 | }; 369 | }; 370 | var useStorex = function () { 371 | var connector = socketConnector({}); 372 | return function (config) { return bindStore(connector, config); }; 373 | }; 374 | var storex = useStorex(); 375 | 376 | exports.default = storex; 377 | exports.httpConnector = httpConnector; 378 | exports.prepare = prepare; 379 | exports.socketConnector = socketConnector; 380 | 381 | Object.defineProperty(exports, '__esModule', { value: true }); 382 | 383 | })); 384 | -------------------------------------------------------------------------------- /test/fixtures/browser/browser_test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |
8 |
9 |
10 |
11 | 12 | 13 |
14 |
15 | 16 |
17 | 18 |
19 |
20 |
21 |
22 | 23 | 24 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /test/fixtures/browser/handler.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Browser.Handler do 2 | def init(request, state) do 3 | {:ok, file} = File.read("./test/fixtures/browser/browser_test.html") 4 | 5 | request = 6 | :cowboy_req.reply( 7 | 200, 8 | %{ 9 | "content-type" => "text/html" 10 | }, 11 | file, 12 | request 13 | ) 14 | 15 | {:ok, request, state} 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/fixtures/browser/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Browser.Plug do 2 | use Plug.Router 3 | 4 | plug(Plug.Static, 5 | at: "/static", 6 | from: "priv/static" 7 | ) 8 | 9 | plug(Storex.Plug, path: "/storex") 10 | 11 | plug(:match) 12 | plug(:dispatch) 13 | 14 | get "/" do 15 | {:ok, file} = File.read("./test/fixtures/browser/browser_test.html") 16 | 17 | send_resp(conn, 200, file) 18 | end 19 | 20 | match _ do 21 | send_resp(conn, 404, "not found") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/fixtures/node.mjs: -------------------------------------------------------------------------------- 1 | import { prepare, httpConnector } from "../../priv/static/storex.cjs.js"; 2 | 3 | const run = async (storeName, json) => { 4 | const params = JSON.parse(json); 5 | 6 | const result = { state: null, error: null }; 7 | 8 | const { useStorex } = prepare( 9 | {}, 10 | httpConnector({ address: "http://localhost:9996/storex" }) 11 | ); 12 | const store = useStorex({ 13 | store: storeName, 14 | params: params, 15 | }); 16 | 17 | await new Promise((resolve) => { 18 | store.subscribe((state) => { 19 | result.state = state; 20 | resolve(); 21 | }); 22 | 23 | store.onError((error) => { 24 | result.error = error; 25 | resolve(); 26 | }); 27 | }); 28 | 29 | console.log(JSON.stringify(result)); 30 | 31 | return "node"; 32 | }; 33 | 34 | export default run; 35 | -------------------------------------------------------------------------------- /test/fixtures/stores/counter.ex: -------------------------------------------------------------------------------- 1 | defmodule StorexTest.Store.Counter do 2 | use Storex.Store 3 | 4 | def init(_session, _params) do 5 | {:ok, 6 | %{ 7 | counter: 0 8 | }} 9 | end 10 | 11 | def mutation("increase", _data, _session_id, _params, state) do 12 | counter = state.counter + 1 13 | 14 | {:noreply, 15 | %{ 16 | counter: counter 17 | }} 18 | end 19 | 20 | def mutation("decrease", _data, _session_id, _params, state) do 21 | counter = state.counter - 1 22 | 23 | {:reply, "decreased", 24 | %{ 25 | counter: counter 26 | }} 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/fixtures/stores/error_init.ex: -------------------------------------------------------------------------------- 1 | defmodule StorexTest.Store.ErrorInit do 2 | use Storex.Store 3 | 4 | def init(_session, _params) do 5 | {:error, "Unauthorized"} 6 | end 7 | 8 | def mutation(_mutation, _data, _session_id, _params, state) do 9 | {:noreply, state} 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/stores/key_init.ex: -------------------------------------------------------------------------------- 1 | defmodule StorexTest.Store.KeyInit do 2 | use Storex.Store 3 | 4 | def init(_session, _params) do 5 | {:ok, 6 | %{ 7 | counter: 0 8 | }, "user_id"} 9 | end 10 | 11 | def mutation("set", [value], _session_id, _params, _state) do 12 | counter = value 13 | 14 | {:reply, "decreased", 15 | %{ 16 | counter: counter 17 | }} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/fixtures/stores/text.ex: -------------------------------------------------------------------------------- 1 | defmodule StorexTest.Store.Text do 2 | use Storex.Store 3 | 4 | def init(_session, params) do 5 | initial_value = Map.get(params, "initial_value", "abc") 6 | 7 | {:ok, initial_value} 8 | end 9 | 10 | def mutation("change", [value], _session_id, _params, _state) do 11 | {:noreply, value} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/storex/browser/bandit_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StorexTest.Browser.Bandit do 2 | use ExUnit.Case 3 | use Wallaby.DSL 4 | 5 | import Wallaby.Query, only: [css: 1, css: 2] 6 | 7 | @port 9997 8 | 9 | setup_all do 10 | Bandit.start_link(plug: MyApp.Browser.Plug, port: @port, startup_log: false) 11 | 12 | :ok 13 | end 14 | 15 | setup do 16 | {:ok, session} = Wallaby.start_session() 17 | 18 | %{session: session} 19 | end 20 | 21 | test "test connected", %{session: session} do 22 | session 23 | |> visit("http://localhost:#{@port}/") 24 | |> assert_has(css(".counter-connected", text: "true")) 25 | end 26 | 27 | test "basic state", %{session: session} do 28 | session 29 | |> visit("http://localhost:#{@port}/") 30 | |> assert_has(css(".counter-value", text: "0")) 31 | end 32 | 33 | test "increase state browser", %{session: session} do 34 | session 35 | |> visit("http://localhost:#{@port}/") 36 | |> click(css(".increase")) 37 | |> assert_has(css(".counter-value", text: "1")) 38 | end 39 | 40 | test "increase state elixir", %{session: session} do 41 | session = 42 | session 43 | |> visit("http://localhost:#{@port}/") 44 | 45 | assert session |> text(css(".session")) |> String.length() > 0 46 | 47 | Storex.mutate("StorexTest.Store.Counter", "increase", []) 48 | 49 | session 50 | |> assert_has(css(".counter-value", text: "1")) 51 | end 52 | 53 | test "decrease state browser reply", %{session: session} do 54 | session 55 | |> visit("http://localhost:#{@port}/") 56 | |> click(css(".decrease")) 57 | |> assert_has(css(".reply", text: "decreased")) 58 | end 59 | 60 | test "set text state", %{session: session} do 61 | session 62 | |> visit("http://localhost:#{@port}/") 63 | |> fill_in(css(".input-text"), with: "John Doe") 64 | |> click(css(".text-send")) 65 | |> assert_has(css(".text-value", text: "John Doe")) 66 | end 67 | 68 | test "test join error", %{session: session} do 69 | session 70 | |> visit("http://localhost:#{@port}/") 71 | |> assert_has(css(".error-message", text: "Unauthorized")) 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/storex/browser/cowboy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StorexTest.Browser.Cowboy do 2 | use ExUnit.Case 3 | use Wallaby.DSL 4 | 5 | import Wallaby.Query, only: [css: 1, css: 2] 6 | 7 | @port 9999 8 | 9 | setup_all do 10 | dispatch = 11 | :cowboy_router.compile([ 12 | {:_, 13 | [ 14 | {"/static/[...]", :cowboy_static, {:dir, "priv/static"}}, 15 | {"/storex", Storex.Handler.Cowboy, []}, 16 | {:_, MyApp.Browser.Handler, []} 17 | ]} 18 | ]) 19 | 20 | {:ok, _} = 21 | :cowboy.start_clear(:test_http, [{:port, @port}], %{ 22 | :env => %{dispatch: dispatch} 23 | }) 24 | 25 | :ok 26 | end 27 | 28 | setup do 29 | {:ok, session} = Wallaby.start_session() 30 | 31 | %{session: session} 32 | end 33 | 34 | test "test connected", %{session: session} do 35 | session 36 | |> visit("http://localhost:#{@port}/") 37 | |> assert_has(css(".counter-connected", text: "true")) 38 | end 39 | 40 | test "basic state", %{session: session} do 41 | session 42 | |> visit("http://localhost:#{@port}/") 43 | |> assert_has(css(".counter-value", text: "0")) 44 | end 45 | 46 | test "increase state browser", %{session: session} do 47 | session 48 | |> visit("http://localhost:#{@port}/") 49 | |> click(css(".increase")) 50 | |> assert_has(css(".counter-value", text: "1")) 51 | end 52 | 53 | test "increase state elixir", %{session: session} do 54 | session = 55 | session 56 | |> visit("http://localhost:#{@port}/") 57 | 58 | assert session |> text(css(".session")) |> String.length() > 0 59 | 60 | Storex.mutate("StorexTest.Store.Counter", "increase", []) 61 | 62 | session 63 | |> assert_has(css(".counter-value", text: "1")) 64 | end 65 | 66 | test "decrease state browser reply", %{session: session} do 67 | session 68 | |> visit("http://localhost:#{@port}/") 69 | |> click(css(".decrease")) 70 | |> assert_has(css(".reply", text: "decreased")) 71 | end 72 | 73 | test "set text state", %{session: session} do 74 | session 75 | |> visit("http://localhost:#{@port}/") 76 | |> fill_in(css(".input-text"), with: "John Doe") 77 | |> click(css(".text-send")) 78 | |> assert_has(css(".text-value", text: "John Doe")) 79 | end 80 | 81 | test "test join error", %{session: session} do 82 | session 83 | |> visit("http://localhost:#{@port}/") 84 | |> assert_has(css(".error-message", text: "Unauthorized")) 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/storex/browser/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StorexTest.Browser.Plug do 2 | use ExUnit.Case 3 | use Wallaby.DSL 4 | 5 | import Wallaby.Query, only: [css: 1, css: 2] 6 | 7 | @port 9998 8 | 9 | setup_all do 10 | Supervisor.start_link( 11 | [{Plug.Cowboy, scheme: :http, plug: MyApp.Browser.Plug, port: @port}], 12 | strategy: :one_for_one, 13 | name: StorexTest.Supervisor 14 | ) 15 | 16 | :ok 17 | end 18 | 19 | setup do 20 | {:ok, session} = Wallaby.start_session() 21 | 22 | %{session: session} 23 | end 24 | 25 | test "test connected", %{session: session} do 26 | session 27 | |> visit("http://localhost:#{@port}/") 28 | |> assert_has(css(".counter-connected", text: "true")) 29 | end 30 | 31 | test "basic state", %{session: session} do 32 | session 33 | |> visit("http://localhost:#{@port}/") 34 | |> assert_has(css(".counter-value", text: "0")) 35 | end 36 | 37 | test "increase state browser", %{session: session} do 38 | session 39 | |> visit("http://localhost:#{@port}/") 40 | |> click(css(".increase")) 41 | |> assert_has(css(".counter-value", text: "1")) 42 | end 43 | 44 | test "increase state elixir", %{session: session} do 45 | session = 46 | session 47 | |> visit("http://localhost:#{@port}/") 48 | |> assert_has(css(".counter-connected", text: "true")) 49 | 50 | Storex.mutate("StorexTest.Store.Counter", "increase", []) 51 | 52 | session 53 | |> assert_has(css(".counter-value", text: "1")) 54 | end 55 | 56 | test "decrease state browser reply", %{session: session} do 57 | session 58 | |> visit("http://localhost:#{@port}/") 59 | |> click(css(".decrease")) 60 | |> assert_has(css(".reply", text: "decreased")) 61 | end 62 | 63 | test "set text state", %{session: session} do 64 | session 65 | |> visit("http://localhost:#{@port}/") 66 | |> fill_in(css(".input-text"), with: "John Doe") 67 | |> click(css(".text-send")) 68 | |> assert_has(css(".text-value", text: "John Doe")) 69 | end 70 | 71 | test "test join error", %{session: session} do 72 | session 73 | |> visit("http://localhost:#{@port}/") 74 | |> assert_has(css(".error-message", text: "Unauthorized")) 75 | end 76 | 77 | def sleep(session, time \\ 10000) do 78 | Process.sleep(time) 79 | 80 | session 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/storex/diff_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StorexTest.Diff do 2 | use ExUnit.Case 3 | doctest Storex 4 | 5 | defmodule Struct do 6 | defstruct [:name, :age] 7 | end 8 | 9 | test "diff binary" do 10 | assert [%{a: "u", p: [], t: "b"}] = Storex.Diff.check("a", "b") 11 | end 12 | 13 | test "diff tuple" do 14 | assert [%{a: "u", p: [], t: :b}] = Storex.Diff.check(:a, :b) 15 | end 16 | 17 | test "diff integer" do 18 | assert [%{a: "u", p: [], t: 2}] = Storex.Diff.check(1, 2) 19 | end 20 | 21 | test "diff float" do 22 | assert [%{a: "u", p: [], t: 1.1}] = Storex.Diff.check(1, 1.1) 23 | end 24 | 25 | test "diff boolean" do 26 | assert [%{a: "u", p: [], t: true}] = Storex.Diff.check(false, true) 27 | end 28 | 29 | test "diff list" do 30 | assert [%{a: "i", p: [2], t: 3}, %{a: "d", p: [1, 1]}, %{a: "u", p: [0], t: 2}] = 31 | Storex.Diff.check([1, [1, 2]], [2, [1], 3]) 32 | end 33 | 34 | test "diff map" do 35 | assert [%{a: "d", p: [:b]}, %{p: [:c], a: "i", t: "c"}, %{a: "u", p: [:a], t: 1}] = 36 | Storex.Diff.check(%{a: "a", b: "b"}, %{a: 1, c: "c"}) |> Enum.sort() 37 | end 38 | 39 | test "diff struct" do 40 | diff = Storex.Diff.check(%Struct{name: "A"}, %Struct{name: "B", age: 10}) 41 | 42 | assert Enum.member?(diff, %{a: "u", p: [:name], t: "B"}) 43 | assert Enum.member?(diff, %{a: "u", p: [:age], t: 10}) 44 | end 45 | 46 | test "diff DateTime" do 47 | assert [%{a: "u", p: [], t: "2000-02-29 23:10:00+01:00 CET Europe/Warsaw"}] = 48 | Storex.Diff.check( 49 | %DateTime{ 50 | year: 2000, 51 | month: 2, 52 | day: 29, 53 | zone_abbr: "CET", 54 | hour: 22, 55 | minute: 0, 56 | second: 7, 57 | microsecond: {0, 0}, 58 | utc_offset: 3600, 59 | std_offset: 0, 60 | time_zone: "Europe/Warsaw" 61 | }, 62 | %DateTime{ 63 | year: 2000, 64 | month: 2, 65 | day: 29, 66 | zone_abbr: "CET", 67 | hour: 23, 68 | minute: 10, 69 | second: 0, 70 | microsecond: {0, 0}, 71 | utc_offset: 3600, 72 | std_offset: 0, 73 | time_zone: "Europe/Warsaw" 74 | } 75 | ) 76 | end 77 | 78 | test "diff NaiveDateTime" do 79 | assert [%{a: "u", p: [], t: "2015-01-23 23:50:07"}] = 80 | Storex.Diff.check( 81 | NaiveDateTime.from_iso8601!("2015-01-22 12:32:12"), 82 | NaiveDateTime.from_iso8601!("2015-01-23 23:50:07") 83 | ) 84 | end 85 | 86 | test "diff Date" do 87 | assert [%{a: "u", p: [], t: "2016-03-01"}] = 88 | Storex.Diff.check( 89 | Date.from_iso8601!("2015-01-23"), 90 | Date.from_iso8601!("2016-03-01") 91 | ) 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/storex/handler/cowboy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StorexTest.Handler.Cowboy do 2 | use ExUnit.Case, async: false 3 | 4 | import StorexTest.HandlerHelpers 5 | 6 | setup_all do 7 | dispatch = 8 | :cowboy_router.compile([ 9 | {:_, 10 | [ 11 | {"/storex", Storex.Handler.Cowboy, []} 12 | ]} 13 | ]) 14 | 15 | {:ok, _} = :cowboy.start_clear(__MODULE__.HTTP, [{:port, 0}], %{env: %{dispatch: dispatch}}) 16 | 17 | {:ok, port: :ranch.get_port(__MODULE__.HTTP)} 18 | end 19 | 20 | describe "init" do 21 | test "success", context do 22 | client = tcp_client(context) 23 | http1_handshake(client) 24 | 25 | send_text_frame(client, """ 26 | { 27 | "type": "join", 28 | "store": "StorexTest.Store.Counter", 29 | "data": {}, 30 | "request": "#{random_string()}" 31 | } 32 | """) 33 | 34 | {:ok, result} = recv_text_frame(client) 35 | 36 | assert %{data: %{counter: 0}, type: "join", store: "StorexTest.Store.Counter"} = 37 | Jason.decode!(result, keys: :atoms) 38 | end 39 | 40 | test "error", context do 41 | client = tcp_client(context) 42 | http1_handshake(client) 43 | 44 | send_text_frame(client, """ 45 | { 46 | "type": "join", 47 | "store": "StorexTest.Store.ErrorInit", 48 | "data": {}, 49 | "request": "#{random_string()}" 50 | } 51 | """) 52 | 53 | {:ok, result} = recv_text_frame(client) 54 | 55 | assert %{error: "Unauthorized", type: "error"} = Jason.decode!(result, keys: :atoms) 56 | end 57 | 58 | test "not existing store", context do 59 | client = tcp_client(context) 60 | http1_handshake(client) 61 | 62 | send_text_frame(client, """ 63 | { 64 | "type": "join", 65 | "store": "StorexTest.Store.NotExisting", 66 | "data": {}, 67 | "request": "#{random_string()}" 68 | } 69 | """) 70 | 71 | assert recv_connection_close_frame(client) == 72 | {:ok, 73 | <<4001::16, 74 | "Store 'StorexTest.Store.NotExisting' is not defined or can't be compiled."::binary>>} 75 | end 76 | 77 | test "without store", context do 78 | client = tcp_client(context) 79 | http1_handshake(client) 80 | 81 | send_text_frame(client, """ 82 | { 83 | "type": "join", 84 | "store": null, 85 | "data": {}, 86 | "request": "#{random_string()}" 87 | } 88 | """) 89 | 90 | assert recv_connection_close_frame(client) == 91 | {:ok, <<4000::16, "Store is not set."::binary>>} 92 | end 93 | end 94 | 95 | describe "mutate" do 96 | test "success", context do 97 | client = tcp_client(context) 98 | http1_handshake(client) 99 | 100 | send_text_frame(client, """ 101 | { 102 | "type": "join", 103 | "store": "StorexTest.Store.Counter", 104 | "data": {}, 105 | "request": "#{random_string()}" 106 | } 107 | """) 108 | 109 | {:ok, result} = recv_text_frame(client) 110 | 111 | assert %{session: session} = Jason.decode!(result, keys: :atoms) 112 | 113 | send_text_frame(client, """ 114 | { 115 | "type": "mutation", 116 | "store": "StorexTest.Store.Counter", 117 | "session": "#{session}", 118 | "data": { 119 | "name": "increase", 120 | "data": [] 121 | }, 122 | "request": "#{random_string()}" 123 | } 124 | """) 125 | 126 | {:ok, result} = recv_text_frame(client) 127 | 128 | assert %{ 129 | diff: [%{p: ["counter"], a: "u", t: 1}], 130 | store: "StorexTest.Store.Counter", 131 | type: "mutation" 132 | } = 133 | Jason.decode!(result, keys: :atoms) 134 | end 135 | 136 | test "not existing mutation", context do 137 | client = tcp_client(context) 138 | http1_handshake(client) 139 | 140 | send_text_frame(client, """ 141 | { 142 | "type": "join", 143 | "store": "StorexTest.Store.Counter", 144 | "data": {}, 145 | "request": "#{random_string()}" 146 | } 147 | """) 148 | 149 | {:ok, result} = recv_text_frame(client) 150 | 151 | assert %{session: session} = Jason.decode!(result, keys: :atoms) 152 | 153 | send_text_frame(client, """ 154 | { 155 | "type": "mutation", 156 | "store": "StorexTest.Store.Counter", 157 | "session": "#{session}", 158 | "data": { 159 | "name": "not_existing", 160 | "data": [] 161 | }, 162 | "request": "#{random_string()}" 163 | } 164 | """) 165 | 166 | {:ok, result} = recv_text_frame(client) 167 | 168 | assert %{ 169 | error: 170 | "No mutation matching \"not_existing\" with data [] in store StorexTest.Store.Counter", 171 | type: "error" 172 | } = 173 | Jason.decode!(result, keys: :atoms) 174 | end 175 | end 176 | 177 | # Simple WebSocket client 178 | 179 | def tcp_client(context) do 180 | {:ok, socket} = :gen_tcp.connect(~c"localhost", context[:port], active: false, mode: :binary) 181 | 182 | socket 183 | end 184 | 185 | def http1_handshake(client) do 186 | :gen_tcp.send(client, """ 187 | GET /storex HTTP/1.1\r 188 | Host: localhost\r 189 | Upgrade: websocket\r 190 | Connection: Upgrade\r 191 | Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r 192 | Sec-WebSocket-Version: 13\r 193 | \r 194 | """) 195 | 196 | {:ok, response} = :gen_tcp.recv(client, 0, 6000) 197 | 198 | [ 199 | "HTTP/1.1 101 Switching Protocols", 200 | "connection: Upgrade", 201 | "date: " <> _, 202 | "sec-websocket-accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", 203 | "server: Cowboy", 204 | "upgrade: websocket", 205 | "", 206 | "" 207 | ] = String.split(response, "\r\n") 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /test/storex/handler/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StorexTest.Handler.Plug do 2 | use ExUnit.Case, async: false 3 | 4 | import StorexTest.HandlerHelpers 5 | 6 | setup_all do 7 | {:ok, _} = Plug.Cowboy.http(__MODULE__, [], port: 0, protocol_options: [idle_timeout: 1000]) 8 | on_exit(fn -> :ok = Plug.Cowboy.shutdown(__MODULE__.HTTP) end) 9 | {:ok, port: :ranch.get_port(__MODULE__.HTTP)} 10 | end 11 | 12 | @behaviour Plug 13 | 14 | @impl Plug 15 | def init(arg), do: arg 16 | 17 | @impl Plug 18 | def call(conn, _opts) do 19 | conn = Plug.Conn.fetch_query_params(conn) 20 | websock = conn.query_params["websock"] |> String.to_atom() 21 | WebSockAdapter.upgrade(conn, websock, [], timeout: 1000) 22 | end 23 | 24 | describe "init" do 25 | test "success", context do 26 | client = tcp_client(context) 27 | http1_handshake(client, Storex.Handler.Plug) 28 | 29 | send_text_frame(client, """ 30 | { 31 | "type": "join", 32 | "store": "StorexTest.Store.Counter", 33 | "data": {}, 34 | "request": "#{random_string()}" 35 | } 36 | """) 37 | 38 | {:ok, result} = recv_text_frame(client) 39 | 40 | assert %{data: %{counter: 0}, type: "join", store: "StorexTest.Store.Counter"} = 41 | Jason.decode!(result, keys: :atoms) 42 | end 43 | 44 | test "error", context do 45 | client = tcp_client(context) 46 | http1_handshake(client, Storex.Handler.Plug) 47 | 48 | send_text_frame(client, """ 49 | { 50 | "type": "join", 51 | "store": "StorexTest.Store.ErrorInit", 52 | "data": {}, 53 | "request": "#{random_string()}" 54 | } 55 | """) 56 | 57 | {:ok, result} = recv_text_frame(client) 58 | 59 | assert %{error: "Unauthorized", type: "error"} = Jason.decode!(result, keys: :atoms) 60 | end 61 | 62 | test "not existing store", context do 63 | client = tcp_client(context) 64 | http1_handshake(client, Storex.Handler.Plug) 65 | 66 | send_text_frame(client, """ 67 | { 68 | "type": "join", 69 | "store": "StorexTest.Store.NotExisting", 70 | "data": {}, 71 | "request": "#{random_string()}" 72 | } 73 | """) 74 | 75 | assert recv_connection_close_frame(client) == 76 | {:ok, 77 | <<4001::16, 78 | "Store 'StorexTest.Store.NotExisting' is not defined or can't be compiled."::binary>>} 79 | end 80 | 81 | test "without store", context do 82 | client = tcp_client(context) 83 | http1_handshake(client, Storex.Handler.Plug) 84 | 85 | send_text_frame(client, """ 86 | { 87 | "type": "join", 88 | "store": null, 89 | "data": {}, 90 | "request": "#{random_string()}" 91 | } 92 | """) 93 | 94 | assert recv_connection_close_frame(client) == 95 | {:ok, <<4000::16, "Store is not set."::binary>>} 96 | end 97 | end 98 | 99 | describe "mutate" do 100 | test "success", context do 101 | client = tcp_client(context) 102 | http1_handshake(client, Storex.Handler.Plug) 103 | 104 | send_text_frame(client, """ 105 | { 106 | "type": "join", 107 | "store": "StorexTest.Store.Counter", 108 | "data": {}, 109 | "request": "#{random_string()}" 110 | } 111 | """) 112 | 113 | {:ok, result} = recv_text_frame(client) 114 | 115 | assert %{session: session} = Jason.decode!(result, keys: :atoms) 116 | 117 | send_text_frame(client, """ 118 | { 119 | "type": "mutation", 120 | "store": "StorexTest.Store.Counter", 121 | "session": "#{session}", 122 | "data": { 123 | "name": "increase", 124 | "data": [] 125 | }, 126 | "request": "#{random_string()}" 127 | } 128 | """) 129 | 130 | {:ok, result} = recv_text_frame(client) 131 | 132 | assert %{ 133 | diff: [%{p: ["counter"], a: "u", t: 1}], 134 | store: "StorexTest.Store.Counter", 135 | type: "mutation" 136 | } = 137 | Jason.decode!(result, keys: :atoms) 138 | end 139 | 140 | test "not existing mutation", context do 141 | client = tcp_client(context) 142 | http1_handshake(client, Storex.Handler.Plug) 143 | 144 | send_text_frame(client, """ 145 | { 146 | "type": "join", 147 | "store": "StorexTest.Store.Counter", 148 | "data": {}, 149 | "request": "#{random_string()}" 150 | } 151 | """) 152 | 153 | {:ok, result} = recv_text_frame(client) 154 | 155 | assert %{session: session} = Jason.decode!(result, keys: :atoms) 156 | 157 | send_text_frame(client, """ 158 | { 159 | "type": "mutation", 160 | "store": "StorexTest.Store.Counter", 161 | "session": "#{session}", 162 | "data": { 163 | "name": "not_existing", 164 | "data": [] 165 | }, 166 | "request": "#{random_string()}" 167 | } 168 | """) 169 | 170 | {:ok, result} = recv_text_frame(client) 171 | 172 | assert %{ 173 | error: 174 | "No mutation matching \"not_existing\" with data [] in store StorexTest.Store.Counter", 175 | type: "error" 176 | } = 177 | Jason.decode!(result, keys: :atoms) 178 | end 179 | end 180 | 181 | # Simple WebSocket client 182 | 183 | def tcp_client(context) do 184 | {:ok, socket} = :gen_tcp.connect(~c"localhost", context[:port], active: false, mode: :binary) 185 | 186 | socket 187 | end 188 | 189 | def http1_handshake(client, module, params \\ []) do 190 | params = params |> Keyword.put(:websock, module) 191 | 192 | :gen_tcp.send(client, """ 193 | GET /?#{URI.encode_query(params)} HTTP/1.1\r 194 | Host: server.example.com\r 195 | Upgrade: websocket\r 196 | Connection: Upgrade\r 197 | Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r 198 | Sec-WebSocket-Version: 13\r 199 | \r 200 | """) 201 | 202 | {:ok, response} = :gen_tcp.recv(client, 234) 203 | 204 | [ 205 | "HTTP/1.1 101 Switching Protocols", 206 | "cache-control: max-age=0, private, must-revalidate", 207 | "connection: Upgrade", 208 | "date: " <> _date, 209 | "sec-websocket-accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", 210 | "server: Cowboy", 211 | "upgrade: websocket", 212 | "", 213 | "" 214 | ] = String.split(response, "\r\n") 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /test/storex/node_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StorexTest.Node do 2 | use ExUnit.Case 3 | 4 | @port 9996 5 | 6 | setup_all do 7 | Bandit.start_link(plug: MyApp.Browser.Plug, port: @port, startup_log: false) 8 | 9 | :ok 10 | end 11 | 12 | test "test connected" do 13 | result = run_store("StorexTest.Store.Counter") 14 | 15 | assert result == %{"error" => nil, "state" => %{"counter" => 0}} 16 | end 17 | 18 | test "test params" do 19 | result = run_store("StorexTest.Store.Text", %{"initial_value" => "custom_text"}) 20 | 21 | assert result == %{"error" => nil, "state" => "custom_text"} 22 | end 23 | 24 | test "test error" do 25 | result = run_store("StorexTest.Store.ErrorInit") 26 | 27 | assert result == %{"error" => "Unauthorized", "state" => nil} 28 | end 29 | 30 | defp run_store(store, params \\ %{}) do 31 | {result, _} = 32 | System.cmd("node", [ 33 | "--input-type=module", 34 | "--trace-uncaught", 35 | "-e", 36 | "import run from './test/fixtures/node.mjs'; run('#{store}', '#{Jason.encode!(params)}')" 37 | ]) 38 | 39 | Jason.decode!(result) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/storex/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StorexTest.Plug do 2 | use ExUnit.Case 3 | 4 | test ":upgraded Bandit" do 5 | options = Storex.Plug.init() 6 | 7 | response = 8 | %Plug.Conn{ 9 | adapter: 10 | {Bandit.Adapter, 11 | %Bandit.Adapter{ 12 | transport: %Bandit.HTTP1.Socket{version: :"HTTP/1.1"}, 13 | opts: %{websocket: []} 14 | }} 15 | } 16 | |> Map.put(:method, "GET") 17 | |> Map.put(:request_path, "/storex") 18 | |> Map.update!(:req_headers, &[{"host", "server.example.com"} | &1]) 19 | |> Plug.Conn.put_req_header("upgrade", "WebSocket") 20 | |> Plug.Conn.put_req_header("connection", "Upgrade") 21 | |> Plug.Conn.put_req_header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==") 22 | |> Plug.Conn.put_req_header("sec-websocket-version", "13") 23 | |> Storex.Plug.call(options) 24 | 25 | assert %{state: :upgraded} = response 26 | end 27 | 28 | test ":upgraded Cowboy" do 29 | options = Storex.Plug.init() 30 | 31 | response = 32 | %Plug.Conn{adapter: {Plug.Cowboy.Conn, %{version: :"HTTP/1.1"}}} 33 | |> Map.put(:method, "GET") 34 | |> Map.put(:request_path, "/storex") 35 | |> Map.update!(:req_headers, &[{"host", "server.example.com"} | &1]) 36 | |> Plug.Conn.put_req_header("upgrade", "WebSocket") 37 | |> Plug.Conn.put_req_header("connection", "Upgrade") 38 | |> Plug.Conn.put_req_header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==") 39 | |> Plug.Conn.put_req_header("sec-websocket-version", "13") 40 | |> Storex.Plug.call(options) 41 | 42 | assert %{state: :upgraded} = response 43 | end 44 | 45 | test ":set Cowboy" do 46 | options = Storex.Plug.init() 47 | 48 | response = 49 | %Plug.Conn{adapter: {Plug.Cowboy.Conn, %{version: :"HTTP/1.1"}}} 50 | |> Map.put(:method, "GET") 51 | |> Map.put(:request_path, "/storex") 52 | |> Map.put(:query_string, "store=StorexTest.Store.Counter¶ms=%7B%7D") 53 | |> Map.update!(:req_headers, &[{"host", "server.example.com"} | &1]) 54 | |> Storex.Plug.call(options) 55 | 56 | assert %{ 57 | resp_body: body, 58 | status: 200 59 | } = response 60 | 61 | assert %{ 62 | "data" => %{"counter" => 0}, 63 | "session" => "SSR", 64 | "store" => "StorexTest.Store.Counter", 65 | "type" => "join" 66 | } = Jason.decode!(body) 67 | end 68 | 69 | test ":unset with POST" do 70 | options = Storex.Plug.init() 71 | 72 | response = 73 | %Plug.Conn{ 74 | adapter: 75 | {Bandit.Adapter, 76 | %Bandit.Adapter{ 77 | transport: %Bandit.HTTP1.Socket{version: :"HTTP/1.1"}, 78 | opts: %{websocket: []} 79 | }} 80 | } 81 | |> Map.put(:method, "POST") 82 | |> Map.put(:request_path, "/storex") 83 | |> Map.update!(:req_headers, &[{"host", "server.example.com"} | &1]) 84 | |> Plug.Conn.put_req_header("upgrade", "WebSocket") 85 | |> Plug.Conn.put_req_header("connection", "Upgrade") 86 | |> Plug.Conn.put_req_header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==") 87 | |> Plug.Conn.put_req_header("sec-websocket-version", "13") 88 | |> Storex.Plug.call(options) 89 | 90 | assert %{state: :unset} = response 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/storex/storex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StorexTest do 2 | use ExUnit.Case 3 | doctest Storex 4 | 5 | defmodule FakeWebsocketServer do 6 | use GenServer 7 | 8 | @impl true 9 | def init([parent_pid, session]) do 10 | {:ok, %{session: session, parent_pid: parent_pid}} 11 | end 12 | 13 | @impl true 14 | def handle_info( 15 | {:mutate, store, mutation, data}, 16 | %{session: session, parent_pid: parent_pid} = state 17 | ) do 18 | %{ 19 | type: "mutation", 20 | session: session, 21 | store: store, 22 | data: %{ 23 | data: data, 24 | name: mutation 25 | } 26 | } 27 | |> Storex.Socket.message_handle(state) 28 | 29 | send(parent_pid, :ok) 30 | 31 | {:noreply, state} 32 | end 33 | end 34 | 35 | describe "global" do 36 | setup do 37 | session = Application.get_env(:storex, :session_id_library, Nanoid).generate() 38 | 39 | {:ok, pid} = 40 | GenServer.start_link(FakeWebsocketServer, [self(), session], name: {:global, session}) 41 | 42 | %{ 43 | session: session, 44 | store: "StorexTest.Store.Counter", 45 | pid: pid 46 | } 47 | end 48 | 49 | test "create store", %{session: session, store: store, pid: pid} do 50 | assert {:ok, _pid} = Storex.Supervisor.add_store(store, session, pid, %{}) 51 | Storex.Supervisor.remove_store(session, store) 52 | end 53 | 54 | test "get store", %{session: session, store: store, pid: pid} do 55 | assert {:ok, _pid} = Storex.Supervisor.add_store(store, session, pid, %{}) 56 | assert %{counter: 0} = Storex.Supervisor.get_store_state(session, store) 57 | Storex.Supervisor.remove_store(session, store) 58 | end 59 | 60 | test "mutate store", %{session: session, store: store, pid: pid} do 61 | assert {:ok, _pid} = Storex.Supervisor.add_store(store, session, pid, %{}) 62 | 63 | Storex.mutate(store, "increase", []) 64 | 65 | assert_receive :ok 66 | 67 | assert %{counter: 1} = Storex.Supervisor.get_store_state(session, store) 68 | end 69 | 70 | test "mutate store in cluster", %{session: session, store: store, pid: pid} do 71 | [node_1] = 72 | LocalCluster.start_nodes(:spawn, 1, 73 | files: [ 74 | __ENV__.file 75 | ] 76 | ) 77 | 78 | assert {:ok, _pid} = Storex.Supervisor.add_store(store, session, pid, %{}) 79 | 80 | Node.spawn(node_1, fn -> 81 | Storex.mutate(store, "increase", []) 82 | end) 83 | 84 | assert_receive :ok 85 | 86 | assert %{counter: 1} = Storex.Supervisor.get_store_state(session, store) 87 | end 88 | end 89 | 90 | describe "key" do 91 | setup do 92 | session = Application.get_env(:storex, :session_id_library, Nanoid).generate() 93 | 94 | {:ok, pid} = 95 | GenServer.start_link(FakeWebsocketServer, [self(), session], name: {:global, session}) 96 | 97 | %{ 98 | session: session, 99 | store: "StorexTest.Store.KeyInit", 100 | pid: pid 101 | } 102 | end 103 | 104 | test "mutate store", %{session: session, store: store, pid: pid} do 105 | [node_1] = 106 | LocalCluster.start_nodes(:spawn, 1, 107 | files: [ 108 | __ENV__.file 109 | ] 110 | ) 111 | 112 | assert {:ok, _pid} = Storex.Supervisor.add_store(store, session, pid, %{}) 113 | 114 | Node.spawn(node_1, fn -> 115 | Storex.mutate("user_id", store, "set", [1]) 116 | end) 117 | 118 | assert_receive :ok 119 | 120 | assert %{counter: 1} = Storex.Supervisor.get_store_state(session, store) 121 | end 122 | 123 | test "don't mutate store for invalid key", %{session: session, store: store, pid: pid} do 124 | assert {:ok, _pid} = Storex.Supervisor.add_store(store, session, pid, %{}) 125 | 126 | Storex.mutate("invalid_key", store, "set", [1]) 127 | 128 | refute_receive :ok 129 | 130 | assert %{counter: 0} = Storex.Supervisor.get_store_state(session, store) 131 | end 132 | 133 | test "don't mutate store for invalid key in cluster", %{ 134 | session: session, 135 | store: store, 136 | pid: pid 137 | } do 138 | [node_1] = 139 | LocalCluster.start_nodes(:spawn, 1, 140 | files: [ 141 | __ENV__.file 142 | ] 143 | ) 144 | 145 | assert {:ok, _pid} = Storex.Supervisor.add_store(store, session, pid, %{}) 146 | 147 | Node.spawn(node_1, fn -> 148 | Storex.mutate("invalid_key", store, "set", [1]) 149 | end) 150 | 151 | refute_receive :ok 152 | 153 | assert %{counter: 0} = Storex.Supervisor.get_store_state(session, store) 154 | end 155 | end 156 | 157 | describe "error" do 158 | setup do 159 | %{ 160 | session: Application.get_env(:storex, :session_id_library, Nanoid).generate(), 161 | store: "StorexTest.Store.ErrorInit" 162 | } 163 | end 164 | 165 | test "create store", %{session: session, store: store} do 166 | assert {:error, "Unauthorized"} = Storex.Supervisor.add_store(store, session, self(), %{}) 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /test/support/handler_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule StorexTest.HandlerHelpers do 2 | def random_string(length \\ 10) do 3 | for _ <- 1..length, into: "", do: <> 4 | end 5 | 6 | def connection_closed_for_reading?(client) do 7 | :gen_tcp.recv(client, 0) == {:error, :closed} 8 | end 9 | 10 | def connection_closed_for_writing?(client) do 11 | :gen_tcp.send(client, <<>>) == {:error, :closed} 12 | end 13 | 14 | def recv_text_frame(client) do 15 | {:ok, 0x8, 0x1, body} = recv_frame(client) 16 | {:ok, body} 17 | end 18 | 19 | def recv_binary_frame(client) do 20 | {:ok, 0x8, 0x2, body} = recv_frame(client) 21 | {:ok, body} 22 | end 23 | 24 | def recv_connection_close_frame(client) do 25 | {:ok, 0x8, 0x8, body} = recv_frame(client) 26 | {:ok, body} 27 | end 28 | 29 | def recv_ping_frame(client) do 30 | {:ok, 0x8, 0x9, body} = recv_frame(client) 31 | {:ok, body} 32 | end 33 | 34 | def recv_pong_frame(client) do 35 | {:ok, 0x8, 0xA, body} = recv_frame(client) 36 | {:ok, body} 37 | end 38 | 39 | defp recv_frame(client) do 40 | {:ok, header} = :gen_tcp.recv(client, 2) 41 | <> = header 42 | 43 | {:ok, data} = 44 | case length do 45 | 0 -> 46 | {:ok, <<>>} 47 | 48 | 126 -> 49 | {:ok, <>} = :gen_tcp.recv(client, 2) 50 | :gen_tcp.recv(client, length) 51 | 52 | 127 -> 53 | {:ok, <>} = :gen_tcp.recv(client, 8) 54 | :gen_tcp.recv(client, length) 55 | 56 | length -> 57 | :gen_tcp.recv(client, length) 58 | end 59 | 60 | {:ok, flags, opcode, data} 61 | end 62 | 63 | def send_continuation_frame(client, data, flags \\ 0x8) do 64 | send_frame(client, flags, 0x0, data) 65 | end 66 | 67 | def send_text_frame(client, data, flags \\ 0x8) do 68 | send_frame(client, flags, 0x1, data) 69 | end 70 | 71 | def send_binary_frame(client, data, flags \\ 0x8) do 72 | send_frame(client, flags, 0x2, data) 73 | end 74 | 75 | def send_connection_close_frame(client, reason) do 76 | send_frame(client, 0x8, 0x8, <>) 77 | end 78 | 79 | def send_ping_frame(client, data) do 80 | send_frame(client, 0x8, 0x9, data) 81 | end 82 | 83 | def send_pong_frame(client, data) do 84 | send_frame(client, 0x8, 0xA, data) 85 | end 86 | 87 | defp send_frame(client, flags, opcode, data) do 88 | mask = :rand.uniform(1_000_000) 89 | masked_data = mask(data, mask) 90 | 91 | mask_flag_and_size = 92 | case byte_size(masked_data) do 93 | size when size <= 125 -> <<1::1, size::7>> 94 | size when size <= 65_535 -> <<1::1, 126::7, size::16>> 95 | size -> <<1::1, 127::7, size::64>> 96 | end 97 | 98 | :gen_tcp.send(client, [<>, mask_flag_and_size, <>, masked_data]) 99 | end 100 | 101 | # Note that masking is an involution, so we don't need a separate unmask function 102 | defp mask(payload, mask, acc \\ <<>>) 103 | 104 | defp mask(payload, mask, acc) when is_integer(mask), do: mask(payload, <>, acc) 105 | 106 | defp mask(<>, <>, acc) do 107 | mask(rest, mask, acc <> <>) 108 | end 109 | 110 | defp mask(<>, <>, acc) do 111 | mask(rest, <>, acc <> <>) 112 | end 113 | 114 | defp mask(<<>>, _mask, acc), do: acc 115 | end 116 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | :ok = LocalCluster.start() 2 | 3 | Application.ensure_all_started(:wallaby) 4 | Application.ensure_all_started(:storex) 5 | 6 | ExUnit.start(timeout: 600_000) 7 | --------------------------------------------------------------------------------