├── .babelrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── dist └── actioncablevue.js ├── jest.setup.js ├── package.json ├── src ├── cable.js ├── index.js ├── logger.js ├── mixin.js ├── plugin.js └── polyfill.js ├── tests ├── cable.spec.js ├── logger.spec.js └── mixin.spec.js ├── tsconfig.json ├── types ├── index.d.ts ├── options.d.ts ├── tsconfig.json └── vue.d.ts ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ], 5 | "ignore": [ 6 | "node_modules" 7 | ], 8 | "sourceMaps": true, 9 | "retainLines": true 10 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: mclintprojects 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behaviour: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behaviour** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Plugin version (please complete the following information):** 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: master 5 | pull_request: 6 | branches: master 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: Install dependencies 14 | uses: borales/actions-yarn@v4.2.0 15 | with: 16 | cmd: install 17 | 18 | - name: Run tests 19 | run: yarn test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | 5 | # Log files 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Editor directories and files 11 | .vscode -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | src/ 3 | tests/ 4 | coverage/ 5 | .babelrc 6 | node_modules/ 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | *.map 11 | 12 | # Editor directories and files 13 | .idea 14 | .vscode 15 | .bitmap 16 | *.suo 17 | *.ntvs* 18 | *.njsproj 19 | *.sln 20 | .github/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mbah Clinton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Last Commit 4 | All Contributors Count 5 | 6 | Bundle Size 7 | Downloads 8 | 9 | 10 | 11 | 12 |

13 | 14 |

ActionCableVue is an easy-to-use Action Cable integration for VueJS.

15 | 16 | #### 🚀 Installation 17 | 18 | ```bash 19 | npm install actioncable-vue --save 20 | ``` 21 | 22 | ```javascript 23 | // Vue 3.x 24 | import { createApp } from "vue"; 25 | import App from "./App.vue"; 26 | import ActionCableVue from "actioncable-vue"; 27 | 28 | const actionCableVueOptions = { 29 | debug: true, 30 | debugLevel: "error", 31 | connectionUrl: "ws://localhost:5000/api/cable", // If you don"t provide a connectionUrl, ActionCable will use the default behavior 32 | connectImmediately: true, 33 | unsubscribeOnUnmount: true, 34 | }; 35 | 36 | createApp(App) 37 | .use(store) 38 | .use(router) 39 | .use(ActionCableVue, actionCableVueOptions) 40 | .mount("#app"); 41 | ``` 42 | 43 | ```javascript 44 | // Vue 2.x 45 | import Vue from "vue"; 46 | import ActionCableVue from "actioncable-vue"; 47 | import App from "./App.vue"; 48 | 49 | Vue.use(ActionCableVue, { 50 | debug: true, 51 | debugLevel: "error", 52 | connectionUrl: "ws://localhost:5000/api/cable", // or function which returns a string with your JWT appended to your server URL as a query parameter 53 | connectImmediately: true, 54 | }); 55 | 56 | new Vue({ 57 | router, 58 | store, 59 | render: (h) => h(App), 60 | }).$mount("#app"); 61 | ``` 62 | 63 | | **Parameters** | **Type** | **Default** | **Required** | **Description** | 64 | | -------------------- | --------------- | ----------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | 65 | | debug | Boolean | `false` | Optional | Enable logging for debug | 66 | | debugLevel | String | `error` | Optional | Debug level required for logging. Either `info`, `error`, or `all` | 67 | | connectionUrl | String/Function | `null` | Optional | ActionCable websocket server url. Omit it for the [default behavior](https://guides.rubyonrails.org/action_cable_overview.html#connect-consumer) | 68 | | connectImmediately | Boolean | `true` | Optional | ActionCable connects to your server immediately. If false, ActionCable connects on the first subscription. | 69 | | unsubscribeOnUnmount | Boolean | `true` | Optional | Unsubscribe from channels when component is unmounted. | 70 | | store | Object | null | Optional | Vuex store | 71 | 72 | #### Table of content 73 | 74 | - [Component Level Usage](https://github.com/mclintprojects/actioncable-vue#-component-level-usage) 75 | - [Subscriptions](https://github.com/mclintprojects/actioncable-vue#-subscriptions) 76 | - [Unsubscriptions](https://github.com/mclintprojects/actioncable-vue#-unsubscriptions) 77 | - [Manually connect to the server](https://github.com/mclintprojects/actioncable-vue#-manually-connect-to-the-server) 78 | - [Disconnecting from the server](https://github.com/mclintprojects/actioncable-vue#-disconnecting-from-the-server) 79 | - [Performing an action on your Action Cable server](https://github.com/mclintprojects/actioncable-vue#-performing-an-action-on-your-action-cable-server) 80 | - [Usage with Vuex](https://github.com/mclintprojects/actioncable-vue#-usage-with-vuex) 81 | - [Usage with Nuxt.JS](https://github.com/mclintprojects/actioncable-vue#-usage-with-nuxtjs) 82 | 83 | #### Wall of Appreciation 84 | 85 | - Many thanks to [@x88BitRain](https://github.com/x8BitRain) for adding Vue 3 compatibility 86 | 87 | #### 🌈 Component Level Usage 88 | 89 | If you want to listen to channel events from your Vue component: 90 | 91 | 1. If you're using **Vue 3** `setup` script define a `channels` object in the `setup` function. 92 | 2. If you're using **Vue 3** `defineComponent` define a `channels` property. 93 | 3. You need to either add a `channels` object in the Vue component **(Vue 2 only)** 94 | 4. If you're using `vue-class-component` define a `channels` property. **(Vue 2 only)** 95 | 96 | Each defined object in `channels` will start to receive events provided you subscribe correctly. 97 | 98 | ##### 1. Vue 3 `setup` script 99 | 100 | ```typescript 101 | 124 | ``` 125 | 126 | ##### 2. Vue 3 `defineComponent` 127 | 128 | ```typescript 129 | import { onMounted } from "vue"; 130 | 131 | export default defineComponent({ 132 | channels: { 133 | ChatChannel: { 134 | connected() { 135 | console.log("connected"); 136 | }, 137 | rejected() { 138 | console.log("rejected"); 139 | }, 140 | received(data) {}, 141 | disconnected() {}, 142 | }, 143 | }, 144 | setup() { 145 | onMounted(() => { 146 | this.$cable.subscribe({ 147 | channel: "ChatChannel", 148 | }); 149 | }); 150 | }, 151 | }); 152 | ``` 153 | 154 | ##### 3. Vue 2.x. 155 | 156 | ```javascript 157 | new Vue({ 158 | data() { 159 | return { 160 | message: "Hello world", 161 | }; 162 | }, 163 | channels: { 164 | ChatChannel: { 165 | connected() {}, 166 | rejected() {}, 167 | received(data) {}, 168 | disconnected() {}, 169 | }, 170 | }, 171 | methods: { 172 | sendMessage: function () { 173 | this.$cable.perform({ 174 | channel: "ChatChannel", 175 | action: "send_message", 176 | data: { 177 | content: this.message, 178 | }, 179 | }); 180 | }, 181 | }, 182 | mounted() { 183 | this.$cable.subscribe({ 184 | channel: "ChatChannel", 185 | room: "public", 186 | }); 187 | }, 188 | }); 189 | ``` 190 | 191 | ##### 4. Vue 2.x `vue-class-component` 192 | 193 | ```typescript 194 | @Component 195 | export default class ChatComponent extends Vue { 196 | @Prop({ required: true }) private id!: string; 197 | 198 | get channels() { 199 | return { 200 | ChatChannel: { 201 | connected() { 202 | console.log("connected"); 203 | }, 204 | rejected() {}, 205 | received(data) {}, 206 | disconnected() {}, 207 | }, 208 | }; 209 | } 210 | 211 | sendMessage() { 212 | this.$cable.perform({ 213 | channel: "ChatChannel", 214 | action: "send_message", 215 | data: { 216 | content: this.message, 217 | }, 218 | }); 219 | } 220 | 221 | async mounted() { 222 | this.$cable.subscribe({ 223 | channel: "ChatChannel", 224 | room: "public", 225 | }); 226 | } 227 | } 228 | ``` 229 | 230 | #### 👂🏾 Subscriptions 231 | 232 | ###### 1. Subscribing to a channel 233 | 234 | Define a `channels` object in your component matching the action cable server channel name you passed for the subscription. 235 | 236 | ```typescript 237 | 255 | ``` 256 | 257 | ### Important ⚠️ 258 | 259 | > ActionCableVue **automatically** uses your ActionCable server channel name if you do not pass in a specific channel name to use in your `channels`. It will also **override** clashing channel names. 260 | 261 | ###### 2. Subscribing to the same channel but different rooms 262 | 263 | ```typescript 264 | 300 | ``` 301 | 302 | ###### 3. Subscribing to a channel with a computed name 303 | 304 | ```typescript 305 | // Conversations.vue 306 | 307 | 315 | ``` 316 | 317 | ```typescript 318 | // Chat.vue 319 | 320 | 347 | ``` 348 | 349 | #### 🔇 Unsubscriptions 350 | 351 | > For Vue 2.x and when using Vue 3.x `defineComponent`, when your component is **destroyed** ActionCableVue **automatically unsubscribes** from any channel **that component** was subscribed to. 352 | 353 | ###### 1. Unsubscribing from a channel (Vue 3.x setup script) 354 | 355 | ```typescript 356 | 362 | ``` 363 | 364 | ###### 2. Unsubscribing from a channel Vue 2.x 365 | 366 | ```javascript 367 | new Vue({ 368 | methods: { 369 | unsubscribe() { 370 | this.$cable.unsubscribe("ChatChannel"); 371 | }, 372 | }, 373 | }); 374 | ``` 375 | 376 | #### 🔌 Manually connect to the server 377 | 378 | ActionCableVue automatically connects to your Action Cable server if `connectImmediately` is not set to `false` during setup. If you do set `connectImmediately` to `false` you can manually trigger a connection to your ActionCable server with `this.$cable.connection.connect`. 379 | 380 | ```typescript 381 | 386 | ``` 387 | 388 | #### ✂️ Disconnecting from the server 389 | 390 | ```typescript 391 | 396 | ``` 397 | 398 | #### 💎 Performing an action on your Action Cable server 399 | 400 | Requires that you have a method defined in your Rails Action Cable channel whose name matches the action property passed in. 401 | 402 | ```typescript 403 | 434 | ``` 435 | 436 | #### 🐬 Usage with Vuex (Vue 2.x) 437 | 438 | ActionCableVue has support for Vuex. All you have to do is setup your store correctly and pass it in during the ActionCableVue plugin setup. 439 | 440 | ```javascript 441 | // store.js 442 | 443 | import Vue from "vue"; 444 | import Vuex from "vuex"; 445 | 446 | Vue.use(Vuex); 447 | 448 | export default new Vuex.Store({ 449 | state: {}, 450 | mutations: { 451 | sendMessage(state, content) { 452 | this.$cable.perform({ 453 | action: "send_message", 454 | data: { 455 | content, 456 | }, 457 | }); 458 | }, 459 | }, 460 | actions: { 461 | sendMessage({ commit }, content) { 462 | commit("sendMessage", content); 463 | }, 464 | }, 465 | }); 466 | ``` 467 | 468 | ```javascript 469 | import store from "./store"; 470 | import Vue from "vue"; 471 | import ActionCableVue from "actioncable-vue"; 472 | 473 | Vue.use(ActionCableVue, { 474 | debug: true, 475 | debugLevel: "all", 476 | connectionUrl: process.env.WEBSOCKET_HOST, 477 | connectImmediately: true, 478 | store, 479 | }); 480 | ``` 481 | 482 | #### 💪 Usage with Nuxt 483 | 484 | ActionCableVue works just fine with Nuxt 2 or 3. All you need to do is set it up as a client side plugin. 485 | 486 | ##### Nuxt 3 487 | 488 | ```javascript 489 | // /plugins/actioncablevue.client.js 490 | import ActionCableVue from "actioncable-vue"; 491 | 492 | export default defineNuxtPlugin(({ vueApp }) => { 493 | const config = useRuntimeConfig(); 494 | 495 | vueApp.use(ActionCableVue, { 496 | debug: true, 497 | debugLevel: "all", 498 | connectionUrl: config.public.WEBSOCKET_HOST, 499 | connectImmediately: true, 500 | }); 501 | }); 502 | 503 | 504 | // /pages/chat.vue 505 | 531 | ``` 532 | 533 | ##### Nuxt 2 534 | 535 | ```javascript 536 | // /plugins/actioncable-vue.js 537 | 538 | import Vue from "vue"; 539 | import ActionCableVue from "actioncable-vue"; 540 | 541 | if (process.client) { 542 | Vue.use(ActionCableVue, { 543 | debug: true, 544 | debugLevel: "all", 545 | connectionUrl: process.env.WEBSOCKET_HOST, 546 | connectImmediately: true, 547 | }); 548 | } 549 | 550 | // nuxt.config.js 551 | plugins: [{ src: "@/plugins/actioncable-vue", ssr: false }]; 552 | ``` 553 | -------------------------------------------------------------------------------- /dist/actioncablevue.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.ActionCableVue=t():e.ActionCableVue=t()}("undefined"!=typeof self?self:this,(()=>(()=>{"use strict";var e={d:(t,n)=>{for(var o in n)e.o(n,o)&&!e.o(t,o)&&Object.defineProperty(t,o,{enumerable:!0,get:n[o]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)},t={};e.d(t,{default:()=>W});var n={logger:"undefined"!=typeof console?console:void 0,WebSocket:"undefined"!=typeof WebSocket?WebSocket:void 0},o={log(...e){this.enabled&&(e.push(Date.now()),n.logger.log("[ActionCable]",...e))}};const i=()=>(new Date).getTime(),r=e=>(i()-e)/1e3;class s{constructor(e){this.visibilityDidChange=this.visibilityDidChange.bind(this),this.connection=e,this.reconnectAttempts=0}start(){this.isRunning()||(this.startedAt=i(),delete this.stoppedAt,this.startPolling(),addEventListener("visibilitychange",this.visibilityDidChange),o.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`))}stop(){this.isRunning()&&(this.stoppedAt=i(),this.stopPolling(),removeEventListener("visibilitychange",this.visibilityDidChange),o.log("ConnectionMonitor stopped"))}isRunning(){return this.startedAt&&!this.stoppedAt}recordMessage(){this.pingedAt=i()}recordConnect(){this.reconnectAttempts=0,delete this.disconnectedAt,o.log("ConnectionMonitor recorded connect")}recordDisconnect(){this.disconnectedAt=i(),o.log("ConnectionMonitor recorded disconnect")}startPolling(){this.stopPolling(),this.poll()}stopPolling(){clearTimeout(this.pollTimeout)}poll(){this.pollTimeout=setTimeout((()=>{this.reconnectIfStale(),this.poll()}),this.getPollInterval())}getPollInterval(){const{staleThreshold:e,reconnectionBackoffRate:t}=this.constructor;return 1e3*e*Math.pow(1+t,Math.min(this.reconnectAttempts,10))*(1+(0===this.reconnectAttempts?1:t)*Math.random())}reconnectIfStale(){this.connectionIsStale()&&(o.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${r(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`),this.reconnectAttempts++,this.disconnectedRecently()?o.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${r(this.disconnectedAt)} s`):(o.log("ConnectionMonitor reopening"),this.connection.reopen()))}get refreshedAt(){return this.pingedAt?this.pingedAt:this.startedAt}connectionIsStale(){return r(this.refreshedAt)>this.constructor.staleThreshold}disconnectedRecently(){return this.disconnectedAt&&r(this.disconnectedAt){!this.connectionIsStale()&&this.connection.isOpen()||(o.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`),this.connection.reopen())}),200)}}s.staleThreshold=6,s.reconnectionBackoffRate=.15;var c={message_types:{welcome:"welcome",disconnect:"disconnect",ping:"ping",confirmation:"confirm_subscription",rejection:"reject_subscription"},disconnect_reasons:{unauthorized:"unauthorized",invalid_request:"invalid_request",server_restart:"server_restart",remote:"remote"},default_mount_path:"/cable",protocols:["actioncable-v1-json","actioncable-unsupported"]};const{message_types:l,protocols:u}=c,a=u.slice(0,u.length-1),h=[].indexOf;class f{constructor(e){this.open=this.open.bind(this),this.consumer=e,this.subscriptions=this.consumer.subscriptions,this.monitor=new s(this),this.disconnected=!0}send(e){return!!this.isOpen()&&(this.webSocket.send(JSON.stringify(e)),!0)}open(){if(this.isActive())return o.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`),!1;{const e=[...u,...this.consumer.subprotocols||[]];return o.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${e}`),this.webSocket&&this.uninstallEventHandlers(),this.webSocket=new n.WebSocket(this.consumer.url,e),this.installEventHandlers(),this.monitor.start(),!0}}close({allowReconnect:e}={allowReconnect:!0}){if(e||this.monitor.stop(),this.isOpen())return this.webSocket.close()}reopen(){if(o.log(`Reopening WebSocket, current state is ${this.getState()}`),!this.isActive())return this.open();try{return this.close()}catch(e){o.log("Failed to reopen WebSocket",e)}finally{o.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`),setTimeout(this.open,this.constructor.reopenDelay)}}getProtocol(){if(this.webSocket)return this.webSocket.protocol}isOpen(){return this.isState("open")}isActive(){return this.isState("open","connecting")}triedToReconnect(){return this.monitor.reconnectAttempts>0}isProtocolSupported(){return h.call(a,this.getProtocol())>=0}isState(...e){return h.call(e,this.getState())>=0}getState(){if(this.webSocket)for(let e in n.WebSocket)if(n.WebSocket[e]===this.webSocket.readyState)return e.toLowerCase();return null}installEventHandlers(){for(let e in this.events){const t=this.events[e].bind(this);this.webSocket[`on${e}`]=t}}uninstallEventHandlers(){for(let e in this.events)this.webSocket[`on${e}`]=function(){}}}f.reopenDelay=500,f.prototype.events={message(e){if(!this.isProtocolSupported())return;const{identifier:t,message:n,reason:i,reconnect:r,type:s}=JSON.parse(e.data);switch(this.monitor.recordMessage(),s){case l.welcome:return this.triedToReconnect()&&(this.reconnectAttempted=!0),this.monitor.recordConnect(),this.subscriptions.reload();case l.disconnect:return o.log(`Disconnecting. Reason: ${i}`),this.close({allowReconnect:r});case l.ping:return null;case l.confirmation:return this.subscriptions.confirmSubscription(t),this.reconnectAttempted?(this.reconnectAttempted=!1,this.subscriptions.notify(t,"connected",{reconnected:!0})):this.subscriptions.notify(t,"connected",{reconnected:!1});case l.rejection:return this.subscriptions.reject(t);default:return this.subscriptions.notify(t,"received",n)}},open(){if(o.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`),this.disconnected=!1,!this.isProtocolSupported())return o.log("Protocol is unsupported. Stopping monitor and disconnecting."),this.close({allowReconnect:!1})},close(e){if(o.log("WebSocket onclose event"),!this.disconnected)return this.disconnected=!0,this.monitor.recordDisconnect(),this.subscriptions.notifyAll("disconnected",{willAttemptReconnect:this.monitor.isRunning()})},error(){o.log("WebSocket onerror event")}};class d{constructor(e,t={},n){this.consumer=e,this.identifier=JSON.stringify(t),function(e,t){if(null!=t)for(let n in t){const o=t[n];e[n]=o}}(this,n)}perform(e,t={}){return t.action=e,this.send(t)}send(e){return this.consumer.send({command:"message",identifier:this.identifier,data:JSON.stringify(e)})}unsubscribe(){return this.consumer.subscriptions.remove(this)}}class b{constructor(e){this.subscriptions=e,this.pendingSubscriptions=[]}guarantee(e){-1==this.pendingSubscriptions.indexOf(e)?(o.log(`SubscriptionGuarantor guaranteeing ${e.identifier}`),this.pendingSubscriptions.push(e)):o.log(`SubscriptionGuarantor already guaranteeing ${e.identifier}`),this.startGuaranteeing()}forget(e){o.log(`SubscriptionGuarantor forgetting ${e.identifier}`),this.pendingSubscriptions=this.pendingSubscriptions.filter((t=>t!==e))}startGuaranteeing(){this.stopGuaranteeing(),this.retrySubscribing()}stopGuaranteeing(){clearTimeout(this.retryTimeout)}retrySubscribing(){this.retryTimeout=setTimeout((()=>{this.subscriptions&&"function"==typeof this.subscriptions.subscribe&&this.pendingSubscriptions.map((e=>{o.log(`SubscriptionGuarantor resubscribing ${e.identifier}`),this.subscriptions.subscribe(e)}))}),500)}}class p{constructor(e){this.consumer=e,this.guarantor=new b(this),this.subscriptions=[]}create(e,t){const n="object"==typeof e?e:{channel:e},o=new d(this.consumer,n,t);return this.add(o)}add(e){return this.subscriptions.push(e),this.consumer.ensureActiveConnection(),this.notify(e,"initialized"),this.subscribe(e),e}remove(e){return this.forget(e),this.findAll(e.identifier).length||this.sendCommand(e,"unsubscribe"),e}reject(e){return this.findAll(e).map((e=>(this.forget(e),this.notify(e,"rejected"),e)))}forget(e){return this.guarantor.forget(e),this.subscriptions=this.subscriptions.filter((t=>t!==e)),e}findAll(e){return this.subscriptions.filter((t=>t.identifier===e))}reload(){return this.subscriptions.map((e=>this.subscribe(e)))}notifyAll(e,...t){return this.subscriptions.map((n=>this.notify(n,e,...t)))}notify(e,t,...n){let o;return o="string"==typeof e?this.findAll(e):[e],o.map((e=>"function"==typeof e[t]?e[t](...n):void 0))}subscribe(e){this.sendCommand(e,"subscribe")&&this.guarantor.guarantee(e)}confirmSubscription(e){o.log(`Subscription confirmed ${e}`),this.findAll(e).map((e=>this.guarantor.forget(e)))}sendCommand(e,t){const{identifier:n}=e;return this.consumer.send({command:t,identifier:n})}}class g{constructor(e){this._url=e,this.subscriptions=new p(this),this.connection=new f(this),this.subprotocols=[]}get url(){return function(e){if("function"==typeof e&&(e=e()),e&&!/^wss?:/i.test(e)){const t=document.createElement("a");return t.href=e,t.href=t.href,t.protocol=t.protocol.replace("http","ws"),t.href}return e}(this._url)}send(e){return this.connection.send(e)}connect(){return this.connection.open()}disconnect(){return this.connection.close({allowReconnect:!1})}ensureActiveConnection(){if(!this.connection.isActive())return this.connection.open()}addSubProtocol(e){this.subprotocols=[...this.subprotocols,e]}}function y(e){return y="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},y(e)}function m(e,t){for(var n=0;n1&&void 0!==arguments[1]?arguments[1]:"error";!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,e),v(this,"_debug",void 0),v(this,"_debugLevel",void 0),this._debug=t,this._debugLevel=n},t=[{key:"log",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"error";!this._debug||"all"!==this._debugLevel&&t!==this._debugLevel||console.log("[".concat(t.toUpperCase(),"] ").concat(e))}}],t&&m(e.prototype,t),Object.defineProperty(e,"prototype",{writable:!1}),e;var e,t}();function w(e){return w="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},w(e)}function O(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,o=Array(t);ne.length)&&(t=e.length);for(var n=0,o=Array(t);n0&&void 0!==arguments[0]?arguments[0]:null;t&&(e._connectionUrl=t),e._cable?e._cable.connect():e._connect(t||e._connectionUrl),e._isReset&&e._resubscribe()},disconnect:function(){e._cable&&(e._cable.disconnect(),e._isReset=!0,e._reset())}}}},{key:"_registerChannel",value:function(e,t){var n=this,o=E(e,2),i=o[0],r=o[1];"computed"!==i?this._addChannel(i,r,t):r.forEach((function(e){var o=e.channelName.call(t),i={connected:e.connected,rejected:e.rejected,disconnected:e.disconnected,received:e.received};n._addChannel(o,i,t)}))}},{key:"_addChannel",value:function(e,t,n){var o=n._uid||n.$.uid;t._uid=o,t._name=e,this._channels[e]||(this._channels[e]=[]),this._addContext(n),!this._channels[e].find((function(e){return e._uid===o}))&&this._contexts[o]&&this._channels[e].push(t)}},{key:"_addContext",value:function(e){var t=e._uid||e.$.uid;void 0!==t&&(this._contexts[t]={context:e})}},{key:"_removeChannel",value:function(e,t){this._channels[e]&&(this._channels[e].splice(this._channels[e].findIndex((function(e){return e._uid===t})),1),delete this._contexts[t],0===this._channels[e].length&&this._channels.subscriptions[e]&&(this._channels.subscriptions[e].unsubscribe(),delete this._channels.subscriptions[e]),this._logger.log("Unsubscribed from channel '".concat(e,"'."),"info"))}},{key:"_fireChannelEvent",value:function(e,t,n){if(Object.prototype.hasOwnProperty.call(this._channels,e))for(var o=this._channels[e],i=0;i/jest.setup.js" 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/cable.js: -------------------------------------------------------------------------------- 1 | import { createConsumer } from "@rails/actioncable"; 2 | import Logger from "./logger"; 3 | import Mixin from "./mixin"; 4 | 5 | export default class Cable { 6 | _logger = null; 7 | _cable = null; 8 | _channels = { subscriptions: {} }; 9 | _contexts = {}; 10 | _connectionUrl = null; 11 | _unsubscribeOnUnmount = true; 12 | _isReset = false; 13 | 14 | /** 15 | * ActionCableVue $cable entry point 16 | * @param {Object} Vue 17 | * @param {Object} options - ActionCableVue options 18 | * @param {string|Function|null} [options.connectionUrl=null] - ActionCable server websocket URL 19 | * @param {boolean} [options.debug=false] - Enable logging for debug 20 | * @param {string} [options.debugLevel="error"] - Debug level required for logging. Either `info`, `error`, or `all` 21 | * @param {boolean} [options.connectImmediately=true] - Connect immediately or wait until the first subscription 22 | * @param {boolean} [options.unsubscribeOnUnmount=true] - Unsubscribe from channels when component is unmounted 23 | * @param {object} options.store - Vuex store 24 | */ 25 | constructor(Vue, options) { 26 | const VERSION = Number(Vue.version.split(".")[0]); 27 | 28 | if (VERSION === 3) { 29 | Vue.config.globalProperties.$cable = this; 30 | } else { 31 | Vue.prototype.$cable = this; 32 | } 33 | 34 | Vue.mixin(Mixin); 35 | 36 | const defaultOptions = { 37 | debug: false, 38 | debugLevel: "error", 39 | connectionUrl: null, 40 | connectImmediately: true, 41 | unsubscribeOnUnmount: true, 42 | store: null, 43 | }; 44 | 45 | let { 46 | debug, 47 | debugLevel, 48 | connectionUrl, 49 | connectImmediately, 50 | store, 51 | unsubscribeOnUnmount, 52 | } = { ...defaultOptions, ...options }; 53 | 54 | this._connectionUrl = connectionUrl; 55 | this._unsubscribeOnUnmount = unsubscribeOnUnmount; 56 | this._logger = new Logger(debug, debugLevel); 57 | 58 | if (connectImmediately !== false) connectImmediately = true; 59 | if (store) store.$cable = this; 60 | if (connectImmediately) this._connect(this._connectionUrl); 61 | 62 | this._attachConnectionObject(); 63 | 64 | return this; 65 | } 66 | 67 | /** 68 | * Subscribes to an Action Cable server channel 69 | * @param {Object} subscription 70 | * @param {string} subscription.channel - The name of the Action Cable server channel 71 | * @param {string} subscription.room - The room in the Action Cable server channel to subscribe to 72 | * @param {string} name - A custom channel name to be used in component 73 | */ 74 | subscribe(subscription, name) { 75 | if (this._cable) { 76 | const channelName = name || subscription.channel; 77 | 78 | this._channels.subscriptions[channelName] = 79 | this._cable.subscriptions.create(subscription, { 80 | connected: () => { 81 | this._fireChannelEvent(channelName, this._channelConnected); 82 | }, 83 | disconnected: () => { 84 | this._fireChannelEvent(channelName, this._channelDisconnected); 85 | }, 86 | rejected: () => { 87 | this._fireChannelEvent(channelName, this._subscriptionRejected); 88 | }, 89 | received: (data) => { 90 | this._fireChannelEvent(channelName, this._channelReceived, data); 91 | }, 92 | }); 93 | } else { 94 | this._connect(this._connectionUrl); 95 | this.subscribe(subscription, name); 96 | } 97 | } 98 | 99 | /** 100 | * Perform an action in an Action Cable server channel 101 | * @param {Object} whatToDo 102 | * @param {string} whatToDo.channel - The name of the Action Cable server channel / The custom name chosen for the component channel 103 | * @param {string} whatToDo.action - The action to call in the Action Cable server channel 104 | * @param {Object} whatToDo.data - The data to pass along with the call to the action 105 | */ 106 | perform(whatToDo) { 107 | const { channel, action, data } = whatToDo; 108 | this._logger.log( 109 | `Performing action '${action}' on channel '${channel}'.`, 110 | "info", 111 | ); 112 | const subscription = this._channels.subscriptions[channel]; 113 | if (subscription) { 114 | subscription.perform(action, data); 115 | this._logger.log( 116 | `Performed '${action}' on channel '${channel}'.`, 117 | "info", 118 | ); 119 | } else { 120 | throw new Error( 121 | `You need to be subscribed to perform action '${action}' on channel '${channel}'.`, 122 | ); 123 | } 124 | } 125 | 126 | /** 127 | * Unsubscribes from an Action Cable server channel 128 | * @param {string} channelName - The name of the Action Cable server channel / The custom name chosen for the component channel 129 | */ 130 | unsubscribe(channelName) { 131 | if (this._unsubscribeOnUnmount && this._channels.subscriptions[channelName]) { 132 | this._channels.subscriptions[channelName].unsubscribe(); 133 | this._logger.log(`Unsubscribed from channel '${channelName}'.`, "info"); 134 | } 135 | } 136 | 137 | /** 138 | * Registers channels for a given context. 139 | * @param {Object} channels - An object containing the channel names and their configurations 140 | * @param {Object} context - The context (typically a Vue component instance) for which the channels are being registered 141 | */ 142 | registerChannels(channels, context) { 143 | Object.entries(channels).forEach((channelEntry) => { 144 | this._registerChannel(channelEntry, context); 145 | }); 146 | } 147 | 148 | /** 149 | * Unregisters channels for a given context. 150 | * @param {Object} channels - An object containing the channel names and their configurations 151 | * @param {Object} context - The context (typically a Vue component instance) for which the channels are being unregistered 152 | */ 153 | unregisterChannels(channels, context) { 154 | Object.entries(channels).forEach((channelEntry) => { 155 | const [channelName, channelValue] = channelEntry; 156 | 157 | if (channelName === "computed") { 158 | channelValue.forEach((channel) => { 159 | const computedChannelName = channel.channelName.call(context); 160 | this._removeChannel(computedChannelName, context._uid); 161 | }); 162 | } else { 163 | this._removeChannel(channelName, context._uid); 164 | } 165 | }); 166 | } 167 | 168 | /** 169 | * Called when a subscription to an Action Cable server channel successfully completes. Calls connected on the component channel 170 | * @param {Object} channel - The component channel 171 | */ 172 | _channelConnected(channel) { 173 | if (channel.connected) { 174 | channel.connected.call(this._contexts[channel._uid].context); 175 | } 176 | 177 | this._logger.log( 178 | `Successfully connected to channel '${channel._name}'.`, 179 | "info", 180 | ); 181 | } 182 | 183 | /** 184 | * Called when a subscription to an Action Cable server channel disconnects. Calls disconnected on the component channel 185 | * @param {Object} channel - The component channel 186 | */ 187 | _channelDisconnected(channel) { 188 | if (channel.disconnected) { 189 | channel.disconnected.call(this._contexts[channel._uid].context); 190 | } 191 | 192 | this._logger.log( 193 | `Successfully disconnected from channel '${channel._name}'.`, 194 | "info", 195 | ); 196 | } 197 | 198 | /** 199 | * Called when a subscription to an Action Cable server channel is rejected by the server. Calls rejected on the component channel 200 | * @param {Object} channel - The component channel 201 | */ 202 | _subscriptionRejected(channel) { 203 | if (channel.rejected) { 204 | channel.rejected.call(this._contexts[channel._uid].context); 205 | } 206 | 207 | this._logger.log(`Subscription rejected for channel '${channel._name}'.`); 208 | } 209 | 210 | /** 211 | * Called when a message from an Action Cable server channel is received. Calls received on the component channel 212 | * @param {Object} channel - The component channel 213 | */ 214 | _channelReceived(channel, data) { 215 | if (channel.received) { 216 | channel.received.call(this._contexts[channel._uid].context, data); 217 | } 218 | 219 | this._logger.log(`Message received on channel '${channel._name}'.`, "info"); 220 | } 221 | 222 | /** 223 | * Connects to an Action Cable server 224 | * @param {string|Function|null} url - The websocket URL of the Action Cable server. 225 | */ 226 | _connect(url) { 227 | if (typeof url === "function") { 228 | this._cable = createConsumer(url()); 229 | } else { 230 | this._cable = createConsumer(url); 231 | } 232 | } 233 | 234 | _attachConnectionObject() { 235 | this.connection = { 236 | /** 237 | * Manually connect to an Action Cable server. Automatically re-subscribes all your subscriptions. 238 | * @param {String|Function|null} [url=null] - Optional parameter. The connection URL to your Action Cable server 239 | */ 240 | connect: (url = null) => { 241 | if (url) this._connectionUrl = url; 242 | 243 | if (this._cable) { 244 | this._cable.connect(); 245 | } else { 246 | this._connect(url || this._connectionUrl); 247 | } 248 | 249 | if (this._isReset) { 250 | this._resubscribe(); 251 | } 252 | }, 253 | /** 254 | * Disconnect from your Action Cable server 255 | */ 256 | disconnect: () => { 257 | if (this._cable) { 258 | this._cable.disconnect(); 259 | this._isReset = true; 260 | this._reset(); 261 | } 262 | }, 263 | }; 264 | } 265 | 266 | /** 267 | * Registers a channel for a given context. 268 | * If the channel is not computed, it adds the channel directly. 269 | * For computed channels, it iterates through each channel and adds them individually. 270 | * 271 | * @param {Array} channel - An array containing the channel name and its configuration 272 | * @param {Object} context - The context (typically a Vue component instance) for which the channel is being registered 273 | * @private 274 | */ 275 | _registerChannel(channel, context) { 276 | const [name, config] = channel; 277 | if (name !== "computed") { 278 | this._addChannel(name, config, context); 279 | } else { 280 | config.forEach((computedChannel) => { 281 | const channelName = computedChannel.channelName.call(context); 282 | const channelObject = { 283 | connected: computedChannel.connected, 284 | rejected: computedChannel.rejected, 285 | disconnected: computedChannel.disconnected, 286 | received: computedChannel.received, 287 | }; 288 | this._addChannel(channelName, channelObject, context); 289 | }); 290 | } 291 | } 292 | 293 | /** 294 | * Component mounted. Retrieves component channels for later use 295 | * @param {string} name - Component channel name 296 | * @param {Object} value - The component channel object itself 297 | * @param {Object} context - The execution context of the component the channel was created in 298 | */ 299 | _addChannel(name, value, context) { 300 | const uid = context._uid || context.$.uid; 301 | value._uid = uid; 302 | value._name = name; 303 | 304 | if (!this._channels[name]) this._channels[name] = []; 305 | this._addContext(context); 306 | 307 | if (!this._channels[name].find((c) => c._uid === uid) && this._contexts[uid]) { 308 | this._channels[name].push(value); 309 | } 310 | } 311 | 312 | /** 313 | * Adds a component to a cache. Component is then used to bind `this` in the component channel to the Vue component's execution context 314 | * @param {Object} context - The Vue component execution context being added 315 | */ 316 | _addContext(context) { 317 | const uid = context._uid || context.$.uid; 318 | if (uid !== undefined) { 319 | this._contexts[uid] = { context }; 320 | } 321 | } 322 | 323 | /** 324 | * Component is destroyed. Removes component's channels, subscription and cached execution context. 325 | */ 326 | _removeChannel(name, uid) { 327 | if (this._channels[name]) { 328 | this._channels[name].splice( 329 | this._channels[name].findIndex((c) => c._uid === uid), 330 | 1, 331 | ); 332 | delete this._contexts[uid]; 333 | 334 | if ( 335 | this._channels[name].length === 0 && 336 | this._channels.subscriptions[name] 337 | ) { 338 | this._channels.subscriptions[name].unsubscribe(); 339 | delete this._channels.subscriptions[name]; 340 | } 341 | 342 | this._logger.log(`Unsubscribed from channel '${name}'.`, "info"); 343 | } 344 | } 345 | 346 | /** 347 | * Fires the event triggered by the Action Cable subscription on the component channel 348 | * @param {string} channelName - The name of the Action Cable server channel / The custom name chosen for the component channel 349 | * @param {Function} callback - The component channel event to call 350 | * @param {Object} data - The data passed from the Action Cable server channel 351 | */ 352 | _fireChannelEvent(channelName, callback, data) { 353 | if (Object.prototype.hasOwnProperty.call(this._channels, channelName)) { 354 | const channelEntries = this._channels[channelName]; 355 | for (let i = 0; i < channelEntries.length; i++) { 356 | callback.call(this, channelEntries[i], data); 357 | } 358 | } 359 | } 360 | 361 | /** 362 | * Resets the component channel cache and every contexts, consumers to initial state because after disconnecting from action cable server we need to be able to re-connect it 363 | */ 364 | _reset() { 365 | this._cable = null; 366 | this._channels = { subscriptions: {} }; 367 | } 368 | 369 | /** 370 | * Resubscribes to a component's channels when we reconnect to the server 371 | */ 372 | _resubscribe() { 373 | Object.keys(this._contexts).forEach((key) => { 374 | const component = this._contexts[key]?.context; 375 | component?.$resubscribeToCableChannels?.(); 376 | }); 377 | } 378 | } 379 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Cable from "./cable"; 2 | 3 | const ActionCableVue = { 4 | /** 5 | * ActionCableVue entry point 6 | * @param {Object} Vue - Vue instance 7 | * @param {Object} options - ActionCableVue options 8 | * @param {string|Function|null} [options.connectionUrl=null] - ActionCable server websocket URL 9 | * @param {boolean} [options.debug=false] - Enable logging for debug 10 | * @param {string} [options.debugLevel='all'] - Debug level required for logging. Either `info`, `error`, or `all` 11 | * @param {boolean} [options.connectImmediately=true] - Connect immediately or wait until the first subscription 12 | * @param {Object} [options.store=null] - Vuex store 13 | * @returns {Cable} - Cable instance 14 | */ 15 | install(Vue, options) { 16 | const { 17 | connectionUrl = null, 18 | debug = false, 19 | debugLevel = 'all', 20 | connectImmediately = true, 21 | store = null 22 | } = options; 23 | 24 | return new Cable(Vue, { 25 | connectionUrl, 26 | debug, 27 | debugLevel, 28 | connectImmediately, 29 | store 30 | }); 31 | }, 32 | }; 33 | 34 | export default ActionCableVue; 35 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {'info' | 'error' | 'all'} LogLevel 3 | */ 4 | 5 | export default class Logger { 6 | /** @type {boolean} */ 7 | _debug; 8 | 9 | /** @type {LogLevel} */ 10 | _debugLevel; 11 | 12 | /** 13 | * ActionCableVue logger entry point 14 | * @param {boolean} debug - Enable logging for debug 15 | * @param {LogLevel} level - Debug level required for logging 16 | */ 17 | constructor(debug, level = 'error') { 18 | this._debug = debug; 19 | this._debugLevel = level; 20 | } 21 | 22 | /** 23 | * Logs a message out to the console 24 | * @param {string} message - The message to log out to the console 25 | * @param {LogLevel} [level='error'] - Debug level for this message 26 | */ 27 | log(message, level = 'error') { 28 | if (this._debug && (this._debugLevel === 'all' || level === this._debugLevel)) { 29 | console.log(`[${level.toUpperCase()}] ${message}`); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/mixin.js: -------------------------------------------------------------------------------- 1 | import objectPolyfill from "./polyfill"; 2 | 3 | function getChannels(context) { 4 | return context.$ ? context.$.type.channels : (context.channels || context.$options.channels); 5 | } 6 | 7 | function hasChannels(context) { 8 | return context.$options.channels || context.channels || (context.$ && context.$.type.channels); 9 | } 10 | 11 | function unsubscribe(context) { 12 | if (!hasChannels(context) || !context.$cable || !context.$cable._unsubscribeOnUnmount) return; 13 | 14 | const channels = getChannels(context); 15 | 16 | Object.entries(channels).forEach(([channelName, channelValue]) => { 17 | if (channelName === "computed") { 18 | channelValue.forEach((channel) => { 19 | const computedChannelName = channel.channelName.call(context); 20 | context.$cable._removeChannel(computedChannelName, context._uid); 21 | }); 22 | } else { 23 | context.$cable._removeChannel(channelName, context._uid); 24 | } 25 | }); 26 | } 27 | 28 | function subscribe(context) { 29 | if (!hasChannels(context)) return; 30 | 31 | objectPolyfill(); 32 | 33 | const channels = getChannels(context); 34 | Object.entries(channels).forEach(entry => { 35 | context.$cable._registerChannel(entry, context); 36 | }); 37 | } 38 | 39 | export default { 40 | /** 41 | * Retrieve channels in component once mounted. 42 | */ 43 | beforeCreate() { 44 | subscribe(this); 45 | }, 46 | /** 47 | * Unsubscribe from channels when component is unmounted. 48 | */ 49 | beforeUnmount() { 50 | unsubscribe(this); 51 | }, 52 | /** 53 | * Unsubscribe from channels when component is destroyed. 54 | */ 55 | beforeDestroy() { 56 | unsubscribe(this); 57 | }, 58 | methods: { 59 | $resubscribeToCableChannels() { 60 | subscribe(this); 61 | }, 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/plugin.js: -------------------------------------------------------------------------------- 1 | export const createActionCablePlugin = (store, cable) => { 2 | store.prototype.$cable = cable; 3 | }; 4 | -------------------------------------------------------------------------------- /src/polyfill.js: -------------------------------------------------------------------------------- 1 | const addObjectDotKeys = () => { 2 | Object.keys = (function () { 3 | "use strict"; 4 | const hasOwnProperty = Object.prototype.hasOwnProperty; 5 | const hasDontEnumBug = Object.prototype.propertyIsEnumerable.call( 6 | !{ toString: null }, 7 | "toString", 8 | ); 9 | const dontEnums = [ 10 | "toString", 11 | "toLocaleString", 12 | "valueOf", 13 | "hasOwnProperty", 14 | "isPrototypeOf", 15 | "propertyIsEnumerable", 16 | "constructor", 17 | ]; 18 | const dontEnumsLength = dontEnums.length; 19 | 20 | return function (obj) { 21 | if ( 22 | typeof obj !== "function" && 23 | (typeof obj !== "object" || obj === null) 24 | ) { 25 | throw new TypeError("Object.keys called on non-object"); 26 | } 27 | 28 | const result = []; 29 | let prop; 30 | let i; 31 | 32 | for (prop in obj) { 33 | if (hasOwnProperty.call(obj, prop)) { 34 | result.push(prop); 35 | } 36 | } 37 | 38 | if (hasDontEnumBug) { 39 | for (i = 0; i < dontEnumsLength; i++) { 40 | if (hasOwnProperty.call(obj, dontEnums[i])) { 41 | result.push(dontEnums[i]); 42 | } 43 | } 44 | } 45 | return result; 46 | }; 47 | })(); 48 | }; 49 | 50 | const addObjectDotEntries = () => { 51 | Object.entries = function (obj) { 52 | const ownProps = Object.keys(obj); 53 | let i = ownProps.length; 54 | const resArray = new Array(i); // preallocate the Array 55 | while (i--) resArray[i] = [ownProps[i], obj[ownProps[i]]]; 56 | 57 | return resArray; 58 | }; 59 | }; 60 | 61 | export default () => { 62 | if (!Object.keys) { 63 | addObjectDotKeys(); 64 | } 65 | 66 | if (!Object.entries) { 67 | addObjectDotEntries(); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /tests/cable.spec.js: -------------------------------------------------------------------------------- 1 | import Cable from "../src/cable"; 2 | import { createApp } from "vue"; 3 | 4 | describe("Cable", () => { 5 | let Vue; 6 | const IS_VUE_3 = Number(process.env.VUE_VER) === 3; 7 | 8 | if (IS_VUE_3) { 9 | Vue = createApp({}); 10 | } else { 11 | Vue = require("vue"); 12 | } 13 | 14 | let cable, create; 15 | global.window = {}; 16 | 17 | beforeEach(() => { 18 | if (!IS_VUE_3) { 19 | Vue.version = "2.6.11"; 20 | Vue.prototype = {}; 21 | } 22 | 23 | Vue.mixin = jest.fn(); 24 | create = jest.fn(); 25 | 26 | cable = new Cable(Vue, { connectionUrl: "ws://localhost:5000/api/cable" }); 27 | 28 | global._cable = { 29 | subscriptions: { 30 | create, 31 | }, 32 | }; 33 | global._channels = { 34 | subscriptions: {}, 35 | }; 36 | global._logger = { log() { } }; 37 | global._contexts = {}; 38 | global._removeChannel = function (name) { 39 | cable._removeChannel.call(global, name); 40 | }; 41 | global._addContext = function (context) { 42 | cable._addContext.call(global, context); 43 | }; 44 | global._connect = jest.fn(); 45 | global.subscribe = jest.fn(); 46 | }); 47 | 48 | test("It should initialize correctly if options provided", () => { 49 | const store = {}; 50 | 51 | cable = new Cable(Vue, { 52 | connectionUrl: "ws://localhost:5000/api/cable", 53 | debug: true, 54 | debugLevel: "error", 55 | store, 56 | }); 57 | IS_VUE_3 58 | ? expect(Vue.config.globalProperties.$cable._cable).toBeDefined() 59 | : expect(Vue.prototype.$cable._cable).toBeDefined(); 60 | expect(Vue.mixin).toHaveBeenCalled(); 61 | expect(cable._logger._debug).toBe(true); 62 | expect(cable._logger._debugLevel).toBe("error"); 63 | expect(cable.connection).toBeDefined(); 64 | expect(store.$cable).toBeDefined(); 65 | }); 66 | 67 | test("It should initialize correctly if options not provided", () => { 68 | cable = new Cable(Vue); 69 | 70 | IS_VUE_3 71 | ? expect(Vue.config.globalProperties.$cable._cable).toBeDefined() 72 | : expect(Vue.prototype.$cable._cable).toBeDefined(); 73 | expect(Vue.mixin).toHaveBeenCalled(); 74 | expect(cable._logger._debug).toBe(false); 75 | expect(cable._logger._debugLevel).toBe("error"); 76 | expect(cable.connection).toBeDefined(); 77 | }); 78 | 79 | test("It should not connect immediately if connectImmediately is false", () => { 80 | cable = new Cable(Vue, { 81 | connectionUrl: "ws://localhost:5000/api/cable", 82 | debug: true, 83 | debugLevel: "error", 84 | connectImmediately: false, 85 | }); 86 | 87 | IS_VUE_3 88 | ? expect(Vue.config.globalProperties.$cable._cable).toBeNull() 89 | : expect(Vue.prototype.$cable._cable).toBeNull(); 90 | }); 91 | 92 | test("It should connect on first subscription if connectImmediately is false", () => { 93 | cable = new Cable(Vue, { 94 | connectionUrl: "ws://localhost:5000/api/cable", 95 | debug: true, 96 | debugLevel: "error", 97 | connectImmediately: false, 98 | }); 99 | 100 | IS_VUE_3 101 | ? expect(Vue.config.globalProperties.$cable._cable).toBeNull() 102 | : expect(Vue.prototype.$cable._cable).toBeNull(); 103 | 104 | create.mockReturnValue({}); 105 | global._cable = null; 106 | 107 | cable.subscribe.call(global, { channel: "ChatChannel" }); 108 | expect(global._connect).toHaveBeenCalled(); 109 | expect(global.subscribe).toHaveBeenCalled(); 110 | }); 111 | 112 | test("It should correctly subscribe to channel", () => { 113 | create.mockReturnValue({}); 114 | cable.subscribe.call(global, { channel: "ChatChannel" }); 115 | expect(global._cable.subscriptions.create).toBeCalled(); 116 | expect(global._channels.subscriptions.ChatChannel).toBeDefined(); 117 | }); 118 | 119 | test("It should correctly subscribe to channel with custom name", () => { 120 | create.mockReturnValue({}); 121 | cable.subscribe.call( 122 | global, 123 | { channel: "ChatChannel" }, 124 | "custom_channel_name", 125 | ); 126 | expect(global._cable.subscriptions.create).toBeCalled(); 127 | expect(global._channels.subscriptions.custom_channel_name).toBeDefined(); 128 | }); 129 | 130 | test("It should correctly subscribe to same channel with multiple custom names", () => { 131 | create.mockReturnValue({}); 132 | cable.subscribe.call( 133 | global, 134 | { channel: "ChatChannel" }, 135 | "custom_channel_name", 136 | ); 137 | cable.subscribe.call( 138 | global, 139 | { channel: "ChatChannel" }, 140 | "custom_channel_name_2", 141 | ); 142 | 143 | expect(global._cable.subscriptions.create).toBeCalledTimes(2); 144 | expect(global._channels.subscriptions.custom_channel_name).toBeDefined(); 145 | expect(global._channels.subscriptions.custom_channel_name_2).toBeDefined(); 146 | }); 147 | 148 | test("It should correctly perform an action on a channel", () => { 149 | const perform = jest.fn(); 150 | const whatToDo = { 151 | channel: "ChatChannel", 152 | action: "send_message", 153 | data: { content: "Hi" }, 154 | }; 155 | global._channels.subscriptions.ChatChannel = { 156 | perform, 157 | }; 158 | 159 | cable.perform.call(global, whatToDo); 160 | expect(perform).toHaveBeenCalledWith(whatToDo.action, whatToDo.data); 161 | }); 162 | 163 | test("It should not perform an action if subscription to channel does not exist", () => { 164 | const whatToDo = { 165 | channel: "ChatChannel", 166 | action: "send_message", 167 | data: { content: "Hi" }, 168 | }; 169 | 170 | const t = () => { 171 | cable.perform.call(global, whatToDo); 172 | }; 173 | expect(t).toThrowError(); 174 | }); 175 | 176 | test("It should correctly fire connected event", () => { 177 | const connected = jest.fn(); 178 | global._channels.ChatChannel = [ 179 | { _uid: 0, name: "ChatChannel", connected }, 180 | { _uid: 1, name: "ChatChannel", connected }, 181 | ]; 182 | global._contexts[0] = { context: this }; 183 | global._contexts[1] = { context: this }; 184 | 185 | cable._fireChannelEvent.call( 186 | global, 187 | "ChatChannel", 188 | cable._channelConnected, 189 | ); 190 | 191 | expect(connected).toBeCalledTimes(2); 192 | }); 193 | 194 | test("It should correctly fire rejected event", () => { 195 | const rejected = jest.fn(); 196 | global._channels.ChatChannel = [{ _uid: 1, name: "ChatChannel", rejected }]; 197 | global._contexts[1] = { context: this }; 198 | 199 | cable._fireChannelEvent.call( 200 | global, 201 | "ChatChannel", 202 | cable._subscriptionRejected, 203 | ); 204 | 205 | expect(rejected).toBeCalledTimes(1); 206 | }); 207 | 208 | test("It should correctly fire disconnected event", () => { 209 | const disconnected = jest.fn(); 210 | global._channels.ChatChannel = [ 211 | { 212 | _uid: 2, 213 | name: "ChatChannel", 214 | disconnected, 215 | }, 216 | ]; 217 | global._contexts[2] = { context: this }; 218 | 219 | cable._fireChannelEvent.call( 220 | global, 221 | "ChatChannel", 222 | cable._channelDisconnected, 223 | ); 224 | 225 | expect(disconnected).toBeCalledTimes(1); 226 | }); 227 | 228 | test("It should correctly fire received event", () => { 229 | const received = jest.fn(); 230 | const data = { age: 1 }; 231 | global._channels.ChatChannel = [{ _uid: 3, name: "ChatChannel", received }]; 232 | global._contexts[3] = { context: this }; 233 | 234 | cable._fireChannelEvent.call( 235 | global, 236 | "ChatChannel", 237 | cable._channelReceived, 238 | data, 239 | ); 240 | 241 | expect(received).toBeCalledTimes(1); 242 | expect(received).toHaveBeenCalledWith(data); 243 | }); 244 | 245 | test("It should correctly unsubscribe from channel", () => { 246 | const unsubscribe = jest.fn(); 247 | const channelName = "ChatChannel"; 248 | const channelUid = 3; 249 | 250 | global._channels.ChatChannel = { 251 | _uid: channelUid, 252 | name: channelName, 253 | }; 254 | global._channels.subscriptions[channelName] = { unsubscribe }; 255 | global._contexts[channelUid] = { users: 1 }; 256 | global._unsubscribeOnUnmount = true; 257 | 258 | cable.unsubscribe.call(global, channelName); 259 | expect(global._channels[channelName]).toBeDefined(); 260 | expect(global._channels.subscriptions[channelName]).toBeDefined(); 261 | expect(global._contexts[channelUid]).toBeDefined(); 262 | expect(unsubscribe).toBeCalledTimes(1); 263 | }); 264 | 265 | test("it should not unsubscribe on unmount if unsubscribeOnUnmount is false", () => { 266 | cable = new Cable(Vue, { 267 | connectionUrl: "ws://localhost:5000/api/cable", 268 | debug: true, 269 | debugLevel: "error", 270 | unsubscribeOnUnmount: false, 271 | }); 272 | 273 | const unsubscribe = jest.fn(); 274 | const channelName = "ChatChannel"; 275 | 276 | global._channels.ChatChannel = { 277 | name: channelName, 278 | }; 279 | global._channels.subscriptions[channelName] = { unsubscribe }; 280 | global._unsubscribeOnUnmount = false; 281 | 282 | cable.unsubscribe.call(global, channelName); 283 | expect(unsubscribe).not.toBeCalled(); 284 | }); 285 | 286 | test("It should remove destroyed component's channel correctly", () => { 287 | const unsubscribe = jest.fn(); 288 | const channelName = "ChatChannel"; 289 | const channelUid = 3; 290 | 291 | global._channels.ChatChannel = [ 292 | { 293 | _uid: channelUid, 294 | name: channelName, 295 | }, 296 | ]; 297 | global._channels.subscriptions[channelName] = { unsubscribe }; 298 | global._contexts[channelUid] = { users: 1 }; 299 | 300 | cable._removeChannel.call(global, channelName, channelUid); 301 | expect(global._channels[channelName].length).toBe(0); 302 | expect(global._channels.subscriptions[channelName]).toBeUndefined(); 303 | expect(global._contexts[channelUid]).toBeUndefined(); 304 | expect(unsubscribe).toBeCalledTimes(1); 305 | }); 306 | 307 | test("It should correctly add context", () => { 308 | const uid = 1; 309 | const context = IS_VUE_3 ? { $: { uid } } : { _uid: uid }; 310 | cable._addContext.call(global, context); 311 | expect(global._contexts[uid]).toBeDefined(); 312 | 313 | cable._addContext.call(global, context); 314 | }); 315 | 316 | test("It should correctly add channels", () => { 317 | const channelName = "ChatChannel"; 318 | const uid = 1; 319 | const context = IS_VUE_3 ? { $: { uid } } : { _uid: uid }; 320 | 321 | cable._addChannel.call(global, channelName, {}, context); 322 | expect(global._channels[channelName]).toBeDefined(); 323 | expect(global._channels[channelName][0]._uid).toEqual(uid); 324 | expect(global._channels[channelName].length).toEqual(1); 325 | expect(global._channels[channelName][0]._name).toEqual(channelName); 326 | expect(global._contexts[uid]).toBeDefined(); 327 | }); 328 | }); 329 | -------------------------------------------------------------------------------- /tests/logger.spec.js: -------------------------------------------------------------------------------- 1 | import Logger from "../src/logger"; 2 | 3 | describe("Logger", () => { 4 | let log; 5 | 6 | beforeEach(() => { 7 | log = jest.fn(); 8 | console.log = log; 9 | }); 10 | 11 | test("It should correctly log if debugLevel is all", () => { 12 | const logger = new Logger(true, "all"); 13 | 14 | logger.log("Hi -- info", "info"); 15 | expect(log).toBeCalledWith("[INFO] Hi -- info"); 16 | 17 | logger.log("Hi -- error", "error"); 18 | expect(log).toBeCalledWith("[ERROR] Hi -- error"); 19 | }); 20 | 21 | test("It should correctly log if debugLevel is info", () => { 22 | const logger = new Logger(true, "info"); 23 | logger.log("Hi -- info", "info"); 24 | expect(log).toBeCalledWith("[INFO] Hi -- info"); 25 | }); 26 | 27 | test("It should correctly log if debugLevel is error", () => { 28 | const logger = new Logger(true, "error"); 29 | logger.log("Hi -- error", "error"); 30 | expect(log).toBeCalledWith("[ERROR] Hi -- error"); 31 | }); 32 | 33 | test("It should not log if debugLevel does not match error type", () => { 34 | const logger = new Logger(true, "info"); 35 | logger.log("Hi -- error", "error"); 36 | expect(log).toBeCalledTimes(0); 37 | }); 38 | 39 | test("It should not log if debug is set to false", () => { 40 | const logger = new Logger(false, "error"); 41 | logger.log("Hi -- error", "error"); 42 | expect(log).toBeCalledTimes(0); 43 | }); 44 | 45 | test("It should log messages as error by default", () => { 46 | const logger = new Logger(true, "error"); 47 | logger.log("Hi -- error"); 48 | expect(log).toBeCalledTimes(1); 49 | expect(log).toHaveBeenCalledWith("[ERROR] Hi -- error"); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tests/mixin.spec.js: -------------------------------------------------------------------------------- 1 | import Mixin from "../src/mixin"; 2 | 3 | describe("Mixin", () => { 4 | let _addChannel; 5 | let _removeChannel; 6 | const userId = 1; 7 | 8 | beforeEach(() => { 9 | _addChannel = jest.fn(); 10 | _removeChannel = jest.fn(); 11 | 12 | global.$options = { 13 | channels: { 14 | ChatChannel: {}, 15 | NotificationChannel: {}, 16 | computed: [ 17 | { 18 | channelName() { 19 | return `${userId}_channel`; 20 | }, 21 | connected() { }, 22 | rejected() { }, 23 | disconnected() { }, 24 | received(data) { 25 | return `${data} was passed in`; 26 | }, 27 | }, 28 | ], 29 | }, 30 | }; 31 | 32 | global.$cable = { 33 | _addChannel, 34 | _removeChannel, 35 | _unsubscribeOnUnmount: true, 36 | }; 37 | }); 38 | 39 | test("It should not load channels on mount if component does not have channels object defined", () => { 40 | global.$options = {}; 41 | Mixin.beforeCreate.call(global); 42 | expect(_addChannel).toBeCalledTimes(0); 43 | }); 44 | 45 | test("It should correctly unsubscribe from channels on destroy", () => { 46 | Mixin.beforeUnmount.call(global); 47 | expect(_removeChannel).toBeCalledTimes(3); 48 | }); 49 | 50 | test("It should not unsubscribe from channels if unsubscribeOnUnmount is not set", () => { 51 | global.$cable._unsubscribeOnUnmount = false; 52 | Mixin.beforeUnmount.call(global); 53 | expect(_removeChannel).toBeCalledTimes(0); 54 | }); 55 | 56 | test("It should not attempt to remove channels on destroy if component does not have channels object defined", () => { 57 | global.$options = {}; 58 | Mixin.beforeUnmount.call(global); 59 | expect(_removeChannel).toBeCalledTimes(0); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "module": "ES6", 5 | "noImplicitAny": true, 6 | "allowJs": true, 7 | "removeComments": true, 8 | "preserveConstEnums": true, 9 | "sourceMap": true 10 | }, 11 | "include": ["src/**/*"] 12 | } 13 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { PluginFunction } from "vue"; 2 | // augment typings of Vue.js 3 | import "./vue"; 4 | 5 | export interface ActionCableVueOptions { 6 | debug?: boolean; 7 | debugLevel?: string; 8 | connectionUrl: () => string | string; 9 | connectImmediately?: boolean; 10 | store?: object; 11 | } 12 | 13 | declare class VueActionCableExt { 14 | static install: PluginFunction; 15 | static defaults: ActionCableVueOptions; 16 | } 17 | 18 | export default VueActionCableExt; 19 | -------------------------------------------------------------------------------- /types/options.d.ts: -------------------------------------------------------------------------------- 1 | export type ChannelOptions = { 2 | [key: string]: { 3 | connected: () => void; 4 | rejected: () => void; 5 | received: (data: {}) => void; 6 | disconnected: () => void; 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // this aligns with Vue's browser support 4 | "target": "es5", 5 | // this enables stricter inference for data properties on `this` 6 | "strict": true, 7 | // if using webpack 2+ or rollup, to leverage tree shaking: 8 | "module": "es2015", 9 | "moduleResolution": "node" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /types/vue.d.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import { ChannelNameWithParams } from "actioncable"; 3 | import { ChannelOptions } from "./options"; 4 | 5 | declare module "vue/types/vue" { 6 | export interface Vue { 7 | $cable: { 8 | subscribe: ( 9 | subscription: string | ChannelNameWithParams, 10 | name?: string, 11 | ) => void; 12 | perform: (whatToDo: { 13 | channel: string; 14 | action: string; 15 | data: object; 16 | }) => void; 17 | unsubscribe: (channelName: string) => void; 18 | connection?: { 19 | connect: (url?: string | (() => string) | null) => void; 20 | disconnect: () => void; 21 | }; 22 | }; 23 | } 24 | } 25 | 26 | declare module "vue/types/options" { 27 | // eslint-disable-next-line no-unused-vars 28 | export interface ComponentOptions { 29 | channels?: ChannelOptions; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: process.env.NODE_ENV, 3 | entry: ["./src/index.js"], 4 | output: { 5 | library: "ActionCableVue", 6 | libraryTarget: "umd", 7 | libraryExport: "default", 8 | filename: "actioncablevue.js", 9 | globalObject: "typeof self !== 'undefined' ? self : this", 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.js$/, 15 | exclude: /node_modules/, 16 | use: { 17 | loader: "babel-loader", 18 | }, 19 | }, 20 | ], 21 | }, 22 | }; 23 | --------------------------------------------------------------------------------