├── .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 |
4 |
5 |
6 |
7 |
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 |
--------------------------------------------------------------------------------