├── .tool-versions ├── packages ├── web │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── LICENSE │ └── src │ │ ├── visibility.js │ │ ├── __tests__ │ │ └── visibility.test.js │ │ └── index.js └── worker │ ├── .gitignore │ ├── README.md │ ├── src │ ├── workerCable.js │ ├── __tests__ │ │ ├── workerCable.test.js │ │ ├── cableWrapper.test.js │ │ └── workerPorts.test.js │ ├── workerPorts.js │ ├── index.js │ └── cableWrapper.js │ ├── package.json │ └── LICENSE ├── assets ├── schema.jpg └── social.jpg ├── browserslist.config.js ├── .yarnrc.yml ├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ └── codeql-analysis.yml ├── .prettierrc.yaml ├── shared ├── uuid.js ├── __tests__ │ └── uuid.test.js └── constants.js ├── jest.config.js ├── babel.config.js ├── tsconfig.json ├── LICENSE ├── .gitignore ├── package.json ├── rollup.config.js ├── .eslintrc └── README.md /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 24.11.0 2 | -------------------------------------------------------------------------------- /packages/web/.gitignore: -------------------------------------------------------------------------------- 1 | # production 2 | dist 3 | -------------------------------------------------------------------------------- /packages/worker/.gitignore: -------------------------------------------------------------------------------- 1 | # production 2 | dist 3 | -------------------------------------------------------------------------------- /assets/schema.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/le0pard/cable-shared-worker/HEAD/assets/schema.jpg -------------------------------------------------------------------------------- /assets/social.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/le0pard/cable-shared-worker/HEAD/assets/social.jpg -------------------------------------------------------------------------------- /browserslist.config.js: -------------------------------------------------------------------------------- 1 | module.exports = ['>0.5%', 'Firefox ESR', 'not dead', 'not ie 11', 'not op_mini all'] 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: false 4 | 5 | yarnPath: .yarn/releases/yarn-4.10.3.cjs 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: ["https://www.buymeacoffee.com/leopard"] 4 | -------------------------------------------------------------------------------- /packages/web/README.md: -------------------------------------------------------------------------------- 1 | # @cable-shared-worker/web 2 | 3 | More info - [https://github.com/le0pard/cable-shared-worker](https://github.com/le0pard/cable-shared-worker#readme) 4 | -------------------------------------------------------------------------------- /packages/worker/README.md: -------------------------------------------------------------------------------- 1 | # @cable-shared-worker/worker 2 | 3 | More info - [https://github.com/le0pard/cable-shared-worker](https://github.com/le0pard/cable-shared-worker#readme) 4 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | # https://prettier.io/docs/en/options.html 2 | semi: false 3 | singleQuote: true 4 | bracketSpacing: true 5 | arrowParens: always 6 | printWidth: 100 7 | trailingComma: none 8 | -------------------------------------------------------------------------------- /shared/uuid.js: -------------------------------------------------------------------------------- 1 | const chr4 = () => Math.random().toString(16).slice(-4) 2 | 3 | export const uuid = () => 4 | `${chr4()}${chr4()}-${chr4()}-${chr4()}-${chr4()}-${chr4()}${chr4()}${chr4()}` 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | process.env.TZ = 'UTC' // normalize timezone for tests 2 | 3 | module.exports = { 4 | coverageReporters: ['json', 'lcov', 'text', 'clover', 'html'], 5 | testEnvironment: 'jsdom' 6 | } 7 | -------------------------------------------------------------------------------- /shared/__tests__/uuid.test.js: -------------------------------------------------------------------------------- 1 | import { uuid } from '../uuid' 2 | 3 | describe('uuid', () => { 4 | it('generate string', () => { 5 | const result = uuid() 6 | expect(result).toEqual(expect.any(String)) 7 | expect(result).toHaveLength(36) 8 | }) 9 | 10 | it('should be uniq', () => { 11 | expect(uuid()).not.toEqual(uuid()) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /packages/worker/src/workerCable.js: -------------------------------------------------------------------------------- 1 | import { ACTIONCABLE_TYPE, ANYCABLE_TYPE } from './../../../shared/constants' 2 | import { initCableWrapper } from './cableWrapper' 3 | 4 | export const loadCableApiWrapper = ( 5 | cableType = ACTIONCABLE_TYPE, 6 | cableLibrary = null, 7 | options = {}, 8 | hooks = {} 9 | ) => { 10 | if (!cableLibrary) { 11 | throw new Error('cableLibrary cannot be null') 12 | } 13 | 14 | switch (cableType?.toLowerCase()) { 15 | case ACTIONCABLE_TYPE: 16 | case ANYCABLE_TYPE: { 17 | return initCableWrapper(cableType.toLowerCase(), cableLibrary, options, hooks) 18 | } 19 | default: { 20 | throw new Error(`${cableType} is not actioncable or anycable type`) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const browserlist = require('./browserslist.config') 2 | 3 | module.exports = (api) => { 4 | const validEnv = ['development', 'test', 'production'] 5 | const currentEnv = api.env() 6 | const isTestEnv = api.env('test') 7 | 8 | if (!validEnv.includes(currentEnv)) { 9 | throw new Error( 10 | 'Please specify a valid `NODE_ENV` or ' + 11 | '`BABEL_ENV` environment variables. Valid values are "development", ' + 12 | '"test", and "production". Instead, received: ' + 13 | JSON.stringify(currentEnv) + 14 | '.' 15 | ) 16 | } 17 | 18 | return { 19 | presets: [ 20 | [ 21 | '@babel/preset-env', 22 | { 23 | targets: browserlist, 24 | modules: isTestEnv ? 'auto' : false, 25 | useBuiltIns: false 26 | } 27 | ] 28 | ], 29 | plugins: [] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/worker/src/__tests__/workerCable.test.js: -------------------------------------------------------------------------------- 1 | import { loadCableApiWrapper } from '../workerCable' 2 | 3 | describe('loadCableApiWrapper', () => { 4 | it('throw error if no provided library', () => { 5 | expect(() => loadCableApiWrapper('actioncable', null)).toThrow('cableLibrary cannot be null') 6 | }) 7 | 8 | it('throw error if not valid library type', () => { 9 | expect(() => loadCableApiWrapper('invalid', {})).toThrow( 10 | 'invalid is not actioncable or anycable type' 11 | ) 12 | }) 13 | 14 | it('support valid types', () => { 15 | expect(() => loadCableApiWrapper('actioncable', {})).not.toThrow() 16 | expect(() => loadCableApiWrapper('anycable', {})).not.toThrow() 17 | }) 18 | 19 | it('support type in different cases', () => { 20 | expect(() => loadCableApiWrapper('ActionCable', {})).not.toThrow() 21 | expect(() => loadCableApiWrapper('ANYCABLE', {})).not.toThrow() 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cable-shared-worker/web", 3 | "version": "0.4.1", 4 | "description": "ActionCable and AnyCable Shared Worker support", 5 | "keywords": [ 6 | "anycable", 7 | "actioncable", 8 | "shared worker", 9 | "visibility api" 10 | ], 11 | "files": [ 12 | "dist/", 13 | "README.md", 14 | "LICENSE" 15 | ], 16 | "main": "dist/index.cjs.js", 17 | "module": "dist/index.esm.js", 18 | "unpkg": "dist/index.umd.js", 19 | "exports": { 20 | ".": { 21 | "import": "./dist/index.esm.js", 22 | "require": "./dist/index.cjs.js" 23 | } 24 | }, 25 | "types": "dist/index.esm.d.ts", 26 | "author": "Alexey Vasiliev", 27 | "license": "MIT", 28 | "repository": "github:le0pard/cable-shared-worker", 29 | "bugs": { 30 | "url": "https://github.com/le0pard/cable-shared-worker/issues" 31 | }, 32 | "homepage": "https://github.com/le0pard/cable-shared-worker#readme" 33 | } 34 | -------------------------------------------------------------------------------- /packages/worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cable-shared-worker/worker", 3 | "version": "0.4.1", 4 | "description": "ActionCable and AnyCable Shared Worker support", 5 | "keywords": [ 6 | "anycable", 7 | "actioncable", 8 | "shared worker", 9 | "visibility api" 10 | ], 11 | "files": [ 12 | "dist/", 13 | "README.md", 14 | "LICENSE" 15 | ], 16 | "main": "dist/index.cjs.js", 17 | "module": "dist/index.esm.js", 18 | "unpkg": "dist/index.umd.js", 19 | "exports": { 20 | ".": { 21 | "import": "./dist/index.esm.js", 22 | "require": "./dist/index.cjs.js" 23 | } 24 | }, 25 | "types": "dist/index.esm.d.ts", 26 | "author": "Alexey Vasiliev", 27 | "license": "MIT", 28 | "repository": "github:le0pard/cable-shared-worker", 29 | "bugs": { 30 | "url": "https://github.com/le0pard/cable-shared-worker/issues" 31 | }, 32 | "homepage": "https://github.com/le0pard/cable-shared-worker#readme" 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "packages/web/dist/index.esm.js", 4 | "packages/worker/dist/index.esm.js" 5 | ], 6 | "compilerOptions": { 7 | "allowJs": true, 8 | // Avoid extra work 9 | "checkJs": false, 10 | // Ensure ".d.ts" modules are generated 11 | "declaration": true, 12 | "declarationMap": true, 13 | // Prevent output to declaration directory 14 | "declarationDir": null, 15 | // Skip ".js" generation 16 | "emitDeclarationOnly": true, 17 | // Single file emission is impossible with this flag set 18 | "isolatedModules": false, 19 | // Generate single file 20 | // `System`, in contrast to `None`, permits the use of `import.meta` 21 | "module": "System", 22 | // Always emit 23 | "noEmit": false, 24 | // Skip code generation when error occurs 25 | "noEmitOnError": true, 26 | // Ignore errors in library type definitions 27 | "skipLibCheck": true, 28 | // Always strip internal exports 29 | "stripInternal": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alexey Vasiliev 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 | -------------------------------------------------------------------------------- /packages/web/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alexey Vasiliev 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 | -------------------------------------------------------------------------------- /shared/constants.js: -------------------------------------------------------------------------------- 1 | export const ACTIONCABLE_TYPE = 'actioncable' 2 | export const ANYCABLE_TYPE = 'anycable' 3 | 4 | const COMMANDS_PREFIX = 'CABLE_SW' 5 | 6 | export const PING_COMMAND = `${COMMANDS_PREFIX}_PING` 7 | export const PONG_COMMAND = `${COMMANDS_PREFIX}_PONG` 8 | export const SUBSCRIBE_TO_CHANNEL = `${COMMANDS_PREFIX}_SUBSCRIBE_TO_CHANNEL` 9 | export const UNSUBSCRIBE_FROM_CHANNEL = `${COMMANDS_PREFIX}_UNSUBSCRIBE_FROM_CHANNEL` 10 | export const WEBSOCKET_PERFORM_COMMAND = `${COMMANDS_PREFIX}_WEBSOCKET_PERFORM` 11 | export const WEBSOCKET_MESSAGE_COMMAND = `${COMMANDS_PREFIX}_WEBSOCKET_MESSAGE` 12 | export const WORKER_MSG_ERROR_COMMAND = `${COMMANDS_PREFIX}_WORKER_MSG_ERROR` 13 | export const VISIBILITY_SHOW_COMMAND = `${COMMANDS_PREFIX}_VISIBILITY_SHOW` 14 | export const VISIBILITY_HIDDEN_COMMAND = `${COMMANDS_PREFIX}_VISIBILITY_HIDDEN` 15 | 16 | export const ALL_COMMANDS = [ 17 | PING_COMMAND, 18 | PONG_COMMAND, 19 | SUBSCRIBE_TO_CHANNEL, 20 | UNSUBSCRIBE_FROM_CHANNEL, 21 | WEBSOCKET_PERFORM_COMMAND, 22 | WEBSOCKET_MESSAGE_COMMAND, 23 | WORKER_MSG_ERROR_COMMAND, 24 | VISIBILITY_SHOW_COMMAND, 25 | VISIBILITY_HIDDEN_COMMAND 26 | ] 27 | -------------------------------------------------------------------------------- /packages/worker/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alexey Vasiliev 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # platform specific 64 | .DS_Store 65 | 66 | # Yarn 2 67 | .yarn/* 68 | !.yarn/patches 69 | !.yarn/releases 70 | !.yarn/plugins 71 | !.yarn/sdks 72 | !.yarn/versions 73 | .pnp.* 74 | 75 | -------------------------------------------------------------------------------- /packages/web/src/visibility.js: -------------------------------------------------------------------------------- 1 | const getVisibilityPropertyNames = () => { 2 | if (typeof document.mozHidden !== 'undefined') { 3 | return ['mozVisibilityState', 'mozvisibilitychange'] 4 | } 5 | 6 | if (typeof document.webkitHidden !== 'undefined') { 7 | return ['webkitVisibilityState', 'webkitvisibilitychange'] 8 | } 9 | 10 | return ['visibilityState', 'visibilitychange'] 11 | } 12 | 13 | const [visibilityState, visibilityChange] = getVisibilityPropertyNames() 14 | 15 | export const activateVisibilityAPI = ({ timeout, visible, hidden }) => { 16 | let visibilityTimer = null 17 | let isChannelsWasPaused = false 18 | 19 | const handleVisibility = () => { 20 | const isVisible = document[visibilityState] === 'visible' 21 | if (isVisible) { 22 | if (visibilityTimer) { 23 | clearTimeout(visibilityTimer) 24 | visibilityTimer = null 25 | } 26 | if (visible) { 27 | visible(isChannelsWasPaused) 28 | } 29 | isChannelsWasPaused = false 30 | } else { 31 | visibilityTimer = setTimeout(() => { 32 | isChannelsWasPaused = true 33 | if (hidden) { 34 | hidden(isChannelsWasPaused) 35 | } 36 | visibilityTimer = null 37 | }, timeout * 1000) 38 | } 39 | } 40 | 41 | document.addEventListener(visibilityChange, handleVisibility) 42 | handleVisibility() // check initial state 43 | return () => { 44 | if (visibilityTimer) { 45 | clearTimeout(visibilityTimer) 46 | visibilityTimer = null 47 | } 48 | isChannelsWasPaused = false 49 | document.removeEventListener(visibilityChange, handleVisibility) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cable-shared-worker", 3 | "private": true, 4 | "version": "0.0.0", 5 | "description": "ActionCable and AnyCable Shared Worker support", 6 | "keywords": [ 7 | "anycable", 8 | "actioncable", 9 | "shared worker", 10 | "visibility api" 11 | ], 12 | "workspaces": [ 13 | "packages/*" 14 | ], 15 | "scripts": { 16 | "build": "rollup --bundleConfigAsCjs -c && tsc", 17 | "dev": "rollup --bundleConfigAsCjs -c -w", 18 | "lint": "pnpify run prettier --check packages/**/src/*.js && eslint packages/**/src/*.js shared/**/*.js *.config.js", 19 | "test": "jest --config jest.config.js", 20 | "test:watch": "jest --config jest.config.js --watch", 21 | "format": "pnpify run prettier --write packages/**/src/*.js" 22 | }, 23 | "author": "Alexey Vasiliev", 24 | "license": "MIT", 25 | "repository": "github:le0pard/cable-shared-worker", 26 | "bugs": { 27 | "url": "https://github.com/le0pard/cable-shared-worker/issues" 28 | }, 29 | "homepage": "https://github.com/le0pard/cable-shared-worker#readme", 30 | "devDependencies": { 31 | "@babel/core": "^7.28.5", 32 | "@babel/eslint-parser": "^7.28.5", 33 | "@babel/preset-env": "^7.28.5", 34 | "@rollup/plugin-alias": "^5.1.1", 35 | "@rollup/plugin-babel": "^6.1.0", 36 | "@rollup/plugin-commonjs": "^28.0.9", 37 | "@rollup/plugin-node-resolve": "^16.0.3", 38 | "@yarnpkg/pnpify": "^4.1.6", 39 | "eslint": "^8.57.1", 40 | "eslint-config-prettier": "^9.1.2", 41 | "eslint-import-resolver-node": "^0.3.9", 42 | "eslint-plugin-import": "^2.32.0", 43 | "jest": "^29.7.0", 44 | "jest-environment-jsdom": "30.2.0", 45 | "jsdom": "^26.1.0", 46 | "prettier": "^3.6.2", 47 | "rollup": "^4.52.5", 48 | "typescript": "^5.9.3" 49 | }, 50 | "packageManager": "yarn@4.10.3" 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Test/Build/Deploy 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | test: 8 | name: "Test" 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | with: 14 | persist-credentials: false 15 | 16 | - name: Install Node.JS 17 | uses: actions/setup-node@v4 18 | with: 19 | cache: 'yarn' 20 | registry-url: 'https://registry.npmjs.org' 21 | scope: '@cable-shared-worker' 22 | node-version-file: '.tool-versions' 23 | 24 | - name: Install Javascript dependencies 25 | run: yarn install --immutable 26 | 27 | - name: Run eslint 28 | run: yarn lint 29 | 30 | - name: Run tests 31 | run: yarn test 32 | 33 | release: 34 | name: "Release" 35 | needs: test 36 | if: startsWith(github.ref, 'refs/tags/') 37 | runs-on: ubuntu-latest 38 | env: 39 | NODE_ENV: production 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v4 43 | with: 44 | persist-credentials: false 45 | 46 | - name: Install Node.JS 47 | uses: actions/setup-node@v4 48 | with: 49 | cache: 'yarn' 50 | registry-url: 'https://registry.npmjs.org' 51 | scope: '@cable-shared-worker' 52 | always-auth: true 53 | node-version-file: '.tool-versions' 54 | 55 | - name: Install Javascript dependencies 56 | run: yarn install --immutable 57 | 58 | - name: Build all files 59 | run: yarn build 60 | 61 | - name: Setup yarn publish settings 62 | run: | 63 | yarn config set npmRegistryServer "https://registry.npmjs.org" 64 | yarn config set npmPublishAccess "public" 65 | 66 | - name: Publish packages 🚀 67 | run: yarn workspaces foreach -W --no-private npm publish 68 | env: 69 | YARN_NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 70 | 71 | -------------------------------------------------------------------------------- /packages/worker/src/workerPorts.js: -------------------------------------------------------------------------------- 1 | import { uuid } from './../../../shared/uuid' 2 | import { PING_COMMAND } from './../../../shared/constants' 3 | 4 | const PORT_TICK_TIME = 5 * 1000 // microseconds 5 | const PORT_MAX_TTL = 21 * 1000 // microseconds 6 | 7 | let activePorts = {} 8 | 9 | const sendPingCommandToPorts = () => { 10 | Object.keys(activePorts).forEach((id) => { 11 | const { port } = activePorts[id] 12 | if (port) { 13 | port.postMessage({ command: PING_COMMAND }) 14 | } 15 | }) 16 | } 17 | 18 | const removeDeadPortsFromStore = (cleanupCallback = () => ({})) => { 19 | sendPingCommandToPorts() // lets ping our ports before remove 20 | const now = new Date() 21 | 22 | activePorts = Object.keys(activePorts).reduce((agg, id) => { 23 | const { pongResponseTime, port } = activePorts[id] 24 | if (pongResponseTime && now.getTime() - pongResponseTime.getTime() > PORT_MAX_TTL) { 25 | // looks like tab was closed 26 | if (cleanupCallback) { 27 | cleanupCallback({ id, port }) 28 | } 29 | return agg 30 | } 31 | return { 32 | ...agg, 33 | [id]: activePorts[id] 34 | } 35 | }, {}) 36 | } 37 | 38 | // needed for tests 39 | export const __getActivePorts = () => activePorts 40 | export const __resetActivePorts = () => { 41 | activePorts = [] 42 | } 43 | 44 | export const addPortForStore = (port) => { 45 | const id = uuid() 46 | activePorts = { 47 | ...activePorts, 48 | [id]: { 49 | port, 50 | pongResponseTime: new Date() 51 | } 52 | } 53 | return id 54 | } 55 | 56 | export const updatePortPongTime = (id) => { 57 | if (activePorts[id]) { 58 | activePorts = { 59 | ...activePorts, 60 | [id]: { 61 | ...activePorts[id], 62 | pongResponseTime: new Date() 63 | } 64 | } 65 | } 66 | } 67 | 68 | export const recurrentPortsChecks = (cleanupCallback = () => ({})) => { 69 | return setInterval(() => { 70 | removeDeadPortsFromStore(cleanupCallback) 71 | }, PORT_TICK_TIME) 72 | } 73 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import alias from '@rollup/plugin-alias' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import commonjs from '@rollup/plugin-commonjs' 4 | import { babel } from '@rollup/plugin-babel' 5 | // packages 6 | import webPkg from './packages/web/package.json' 7 | import workerPkg from './packages/worker/package.json' 8 | 9 | const bannersParams = { 10 | web: webPkg, 11 | worker: workerPkg 12 | } 13 | 14 | const LIBRARY_NAME = 'CableSW' // Library name 15 | const EXTERNAL = [] // external modules 16 | const GLOBALS = {} // https://rollupjs.org/guide/en/#outputglobals 17 | const OUTPUT_DIR = 'dist' 18 | 19 | const makeConfig = () => { 20 | const configs = Object.keys(bannersParams).map((name) => { 21 | const banner = `/*! 22 | * ${bannersParams[name].name} 23 | * ${bannersParams[name].description} 24 | * 25 | * @version v${bannersParams[name].version} 26 | * @author ${bannersParams[name].author} 27 | * @homepage ${bannersParams[name].homepage} 28 | * @repository ${bannersParams[name].repository} 29 | * @license ${bannersParams[name].license} 30 | */` 31 | 32 | return { 33 | input: `packages/${name}/src/index.js`, 34 | external: EXTERNAL, 35 | output: [ 36 | { 37 | banner, 38 | name: LIBRARY_NAME, 39 | file: `packages/${name}/${OUTPUT_DIR}/index.umd.js`, // UMD 40 | format: 'umd', 41 | exports: 'auto', 42 | globals: GLOBALS, 43 | sourcemap: true 44 | }, 45 | { 46 | banner, 47 | file: `packages/${name}/${OUTPUT_DIR}/index.cjs.js`, // CommonJS 48 | format: 'cjs', 49 | exports: 'named', // https://rollupjs.org/guide/en/#outputexports 50 | globals: GLOBALS, 51 | sourcemap: true 52 | }, 53 | { 54 | banner, 55 | file: `packages/${name}/${OUTPUT_DIR}/index.esm.js`, // ESM 56 | format: 'es', 57 | exports: 'auto', 58 | globals: GLOBALS, 59 | sourcemap: true 60 | } 61 | ], 62 | plugins: [ 63 | alias({ 64 | entries: [{ find: /^cable-shared\/(.*)/, replacement: './shared/$1.js' }] 65 | }), 66 | resolve(), // teach Rollup how to find external modules 67 | commonjs(), // so Rollup can convert external modules to an ES module 68 | babel({ 69 | babelHelpers: 'bundled', 70 | exclude: ['node_modules/**'] 71 | }) 72 | ] 73 | } 74 | }) 75 | 76 | return configs 77 | } 78 | 79 | export default () => { 80 | return makeConfig() 81 | } 82 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: 'CodeQL' 13 | 14 | on: 15 | push: 16 | branches: [main] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [main] 20 | schedule: 21 | - cron: '24 11 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ['javascript'] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v3 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v3 71 | -------------------------------------------------------------------------------- /packages/web/src/__tests__/visibility.test.js: -------------------------------------------------------------------------------- 1 | import { activateVisibilityAPI } from '../visibility' 2 | 3 | const triggerVisibilityAPI = (isVisible = true) => { 4 | const visibleKey = isVisible ? 'visible' : 'hidden' 5 | Object.defineProperty(document, 'visibilityState', {value: visibleKey, writable: true}) 6 | Object.defineProperty(document, 'hidden', {value: !isVisible, writable: true}) 7 | document.dispatchEvent(new Event('visibilitychange')) 8 | } 9 | 10 | describe('activateVisibilityAPI', () => { 11 | let disableVisibilityFn = null 12 | 13 | beforeEach(() => { 14 | jest.useFakeTimers() 15 | disableVisibilityFn = null 16 | }) 17 | 18 | afterEach(() => { 19 | if (disableVisibilityFn) { 20 | disableVisibilityFn() 21 | } 22 | jest.useRealTimers() 23 | }) 24 | 25 | it('not fail if no functions', () => { 26 | expect(() => { 27 | disableVisibilityFn = activateVisibilityAPI({ 28 | timeout: 1 29 | }) 30 | 31 | triggerVisibilityAPI(true) 32 | triggerVisibilityAPI(false) 33 | 34 | jest.advanceTimersByTime(1000) // call function hidden 35 | }).not.toThrow() 36 | }) 37 | 38 | it('call visible', () => { 39 | const mockVisible = jest.fn() 40 | const mockHidden = jest.fn() 41 | 42 | disableVisibilityFn = activateVisibilityAPI({ 43 | timeout: 1, 44 | visible: mockVisible 45 | }) 46 | 47 | triggerVisibilityAPI(true) 48 | 49 | expect(mockVisible.mock.calls.length).toBe(1) 50 | expect(mockHidden.mock.calls.length).toBe(0) 51 | }) 52 | 53 | it('call hidden and show', () => { 54 | const mockVisible = jest.fn() 55 | const mockHidden = jest.fn() 56 | 57 | disableVisibilityFn = activateVisibilityAPI({ 58 | timeout: 5, 59 | visible: mockVisible, 60 | hidden: mockHidden 61 | }) 62 | 63 | expect(mockHidden.mock.calls.length).toBe(0) 64 | expect(mockVisible.mock.calls.length).toBe(1) // initial setup 65 | 66 | jest.clearAllMocks() 67 | 68 | triggerVisibilityAPI(false) // no changes 69 | 70 | expect(mockHidden.mock.calls.length).toBe(0) 71 | expect(mockVisible.mock.calls.length).toBe(0) 72 | 73 | jest.advanceTimersByTime(2000) // still not call function hidden 74 | 75 | expect(mockHidden.mock.calls.length).toBe(0) 76 | expect(mockVisible.mock.calls.length).toBe(0) 77 | 78 | jest.advanceTimersByTime(3000) // call function hidden 79 | 80 | expect(mockVisible.mock.calls.length).toBe(0) 81 | expect(mockHidden.mock.calls.length).toBe(1) 82 | expect(mockHidden.mock.calls[0][0]).toBe(true) 83 | 84 | jest.clearAllMocks() 85 | 86 | triggerVisibilityAPI(true) 87 | 88 | expect(mockHidden.mock.calls.length).toBe(0) 89 | expect(mockVisible.mock.calls.length).toBe(1) 90 | expect(mockVisible.mock.calls[0][0]).toBe(true) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /packages/worker/src/__tests__/cableWrapper.test.js: -------------------------------------------------------------------------------- 1 | import { initCableWrapper } from '../cableWrapper' 2 | 3 | describe('initCableWrapper', () => { 4 | afterEach(() => { 5 | jest.clearAllMocks() 6 | }) 7 | 8 | describe('createCable', () => { 9 | describe('actioncable', () => { 10 | let api = { 11 | createConsumer: jest.fn() 12 | } 13 | 14 | it('call createConsumer', () => { 15 | const wrapper = initCableWrapper('actioncable', api) 16 | wrapper.createCable('ws://url') 17 | 18 | expect(api.createConsumer.mock.calls.length).toBe(1) 19 | expect(api.createConsumer.mock.calls[0][0]).toBe('ws://url') 20 | }) 21 | }) 22 | 23 | describe('anycable', () => { 24 | let api = { 25 | createCable: jest.fn() 26 | } 27 | 28 | it('call createCable', () => { 29 | const wrapper = initCableWrapper('anycable', api) 30 | wrapper.createCable('ws://url', {protocol: 'test'}) 31 | 32 | expect(api.createCable.mock.calls.length).toBe(1) 33 | expect(api.createCable.mock.calls[0][0]).toBe('ws://url') 34 | expect(api.createCable.mock.calls[0][1]).toEqual({protocol: 'test'}) 35 | }) 36 | }) 37 | }) 38 | 39 | describe('subscribeTo', () => { 40 | describe('actioncable', () => { 41 | const port = { 42 | postMessage: jest.fn() 43 | } 44 | const wsInterface = { 45 | connect: jest.fn(), 46 | subscriptions: { 47 | create: jest.fn() 48 | } 49 | } 50 | const api = { 51 | createConsumer: () => wsInterface 52 | } 53 | const wrapper = initCableWrapper('actioncable', api) 54 | 55 | beforeEach(() => { 56 | wrapper.createCable('ws://url') 57 | }) 58 | 59 | it('create subscription', () => { 60 | wrapper.subscribeTo({ 61 | port, 62 | portID: 'some-id', 63 | id: 'another-id', 64 | channel: 'ChatChannel', 65 | params: {chatID: 42} 66 | }) 67 | 68 | expect(wsInterface.subscriptions.create.mock.calls.length).toBe(1) 69 | expect(wsInterface.subscriptions.create.mock.calls[0][0]).toEqual({ 70 | channel: 'ChatChannel', 71 | chatID: 42 72 | }) 73 | }) 74 | }) 75 | 76 | describe('anycable', () => { 77 | const channelInterface = { 78 | on: jest.fn() 79 | } 80 | const wsInterface = { 81 | connect: jest.fn(), 82 | subscribeTo: jest.fn(() => new Promise((resolve) => resolve(channelInterface))) 83 | } 84 | const api = { 85 | createCable: () => wsInterface 86 | } 87 | const wrapper = initCableWrapper('anycable', api) 88 | 89 | beforeEach(() => { 90 | wrapper.createCable('ws://url') 91 | }) 92 | 93 | it('create subscription', async () => { 94 | await wrapper.subscribeTo({ 95 | port: {}, 96 | portID: 'some-id', 97 | id: 'another-id', 98 | channel: 'ChatChannel', 99 | params: {chatID: 42} 100 | }) 101 | 102 | expect(wsInterface.subscribeTo.mock.calls.length).toBe(1) 103 | expect(wsInterface.subscribeTo.mock.calls[0][0]).toEqual('ChatChannel') 104 | expect(wsInterface.subscribeTo.mock.calls[0][1]).toEqual({chatID: 42}) 105 | expect(channelInterface.on.mock.calls.length).toBe(1) 106 | expect(channelInterface.on.mock.calls[0][0]).toEqual('message') 107 | }) 108 | }) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /packages/worker/src/__tests__/workerPorts.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | addPortForStore, 3 | updatePortPongTime, 4 | recurrentPortsChecks, 5 | __getActivePorts, 6 | __resetActivePorts 7 | } from '../workerPorts' 8 | 9 | describe('alive ports logic', () => { 10 | let aliveTimer = null 11 | 12 | beforeEach(() => { 13 | jest.useFakeTimers() 14 | __resetActivePorts() 15 | }) 16 | 17 | afterEach(() => { 18 | if (aliveTimer) { 19 | clearInterval(aliveTimer) 20 | aliveTimer = null 21 | } 22 | jest.useRealTimers() 23 | }) 24 | 25 | it('generate id and save port', () => { 26 | const port = { 27 | postMessage: () => {} 28 | } 29 | const id = addPortForStore(port) 30 | 31 | const portsData = __getActivePorts() 32 | expect(portsData[id]).toBeDefined() 33 | expect(portsData[id].port).toBe(port) 34 | expect(portsData[id].pongResponseTime).toBeDefined() 35 | 36 | const aMinuteAgo = new Date(Date.now() - 1000 * 60) 37 | const aMinuteAhead = new Date(Date.now() + 1000 * 60) 38 | expect(portsData[id].pongResponseTime > aMinuteAgo).toBe(true) 39 | expect(portsData[id].pongResponseTime < aMinuteAhead).toBe(true) 40 | }) 41 | 42 | it('generate id uniq ids', () => { 43 | const port = { 44 | postMessage: () => {} 45 | } 46 | const oneId = addPortForStore(port) 47 | const secondId = addPortForStore(port) 48 | 49 | expect(oneId).not.toEqual(secondId) 50 | 51 | const portsData = __getActivePorts() 52 | expect(portsData[oneId]).toBeDefined() 53 | expect(portsData[secondId]).toBeDefined() 54 | }) 55 | 56 | it('remove dead port in 25 seconds', () => { 57 | const port = { 58 | postMessage: () => {} 59 | } 60 | 61 | const id = addPortForStore(port) 62 | aliveTimer = recurrentPortsChecks() 63 | 64 | jest.advanceTimersByTime(10000) 65 | 66 | expect(__getActivePorts()[id]).toBeDefined() 67 | 68 | jest.advanceTimersByTime(15000) 69 | 70 | expect(__getActivePorts()[id]).not.toBeDefined() 71 | }) 72 | 73 | it('alive port need to stay in store', () => { 74 | const alivePort = { 75 | postMessage: () => {} 76 | } 77 | const deadPort = { 78 | postMessage: () => {} 79 | } 80 | const spy = jest.spyOn(alivePort, 'postMessage') 81 | 82 | const aliveId = addPortForStore(alivePort) 83 | const deadId = addPortForStore(deadPort) 84 | 85 | aliveTimer = recurrentPortsChecks() 86 | 87 | jest.advanceTimersByTime(10000) 88 | 89 | expect(spy).toHaveBeenCalledTimes(2) 90 | expect(spy).toHaveBeenNthCalledWith(1, {command: 'CABLE_SW_PING'}) 91 | expect(spy).toHaveBeenNthCalledWith(2, {command: 'CABLE_SW_PING'}) 92 | expect(Object.keys(__getActivePorts()).length).toEqual(2) 93 | expect(__getActivePorts()[aliveId]).toBeDefined() 94 | expect(__getActivePorts()[deadId]).toBeDefined() 95 | 96 | updatePortPongTime(aliveId) // update port alive 97 | 98 | jest.clearAllMocks() 99 | 100 | jest.advanceTimersByTime(15000) 101 | 102 | expect(spy).toHaveBeenCalledTimes(3) 103 | expect(spy).toHaveBeenNthCalledWith(1, {command: 'CABLE_SW_PING'}) 104 | expect(spy).toHaveBeenNthCalledWith(2, {command: 'CABLE_SW_PING'}) 105 | expect(spy).toHaveBeenNthCalledWith(3, {command: 'CABLE_SW_PING'}) 106 | expect(Object.keys(__getActivePorts()).length).toEqual(1) 107 | expect(__getActivePorts()[aliveId]).toBeDefined() 108 | expect(__getActivePorts()[deadId]).not.toBeDefined() 109 | 110 | spy.mockRestore() 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@babel/eslint-parser", 3 | "parserOptions": { 4 | "sourceType": "module", 5 | "ecmaFeatures": {} 6 | }, 7 | "extends": ["eslint:recommended", "prettier"], 8 | "ignorePatterns": ["packages/*/dist/*.js"], 9 | "rules": { 10 | "array-bracket-spacing": 2, 11 | "brace-style": [ 12 | 2, 13 | "1tbs", 14 | { 15 | "allowSingleLine": true 16 | } 17 | ], 18 | "camelcase": [ 19 | 2, 20 | { 21 | "properties": "never", 22 | "ignoreDestructuring": false 23 | } 24 | ], 25 | "comma-dangle": [2, "never"], 26 | "comma-spacing": [ 27 | 2, 28 | { 29 | "before": false, 30 | "after": true 31 | } 32 | ], 33 | "comma-style": [2, "last"], 34 | "computed-property-spacing": 2, 35 | "consistent-return": 2, 36 | "consistent-this": [2, "self"], 37 | "curly": [2, "multi-line", "consistent"], 38 | "eol-last": 2, 39 | "eqeqeq": [2, "allow-null"], 40 | "func-style": [ 41 | 2, 42 | "expression", 43 | { 44 | "allowArrowFunctions": true 45 | } 46 | ], 47 | "guard-for-in": 2, 48 | "handle-callback-err": [2, "^(err|error)$"], 49 | "import/no-unresolved": 0, 50 | "key-spacing": [ 51 | 0, 52 | { 53 | "align": "value" 54 | } 55 | ], 56 | "keyword-spacing": [ 57 | 2, 58 | { 59 | "before": true, 60 | "after": true 61 | } 62 | ], 63 | "new-cap": [ 64 | 2, 65 | { 66 | "capIsNew": false, 67 | "newIsCap": true 68 | } 69 | ], 70 | "new-parens": 0, 71 | "no-bitwise": 0, 72 | "no-cond-assign": 2, 73 | "no-console": 2, 74 | "no-constant-condition": 2, 75 | "no-debugger": 2, 76 | "no-delete-var": 2, 77 | "no-dupe-keys": 2, 78 | "no-duplicate-case": 2, 79 | "no-empty": 2, 80 | "no-empty-character-class": 2, 81 | "no-eval": 2, 82 | "no-ex-assign": 2, 83 | "no-extend-native": 2, 84 | "no-extra-parens": [2, "functions"], 85 | "no-extra-semi": 2, 86 | "no-floating-decimal": 2, 87 | "no-func-assign": 2, 88 | "no-implied-eval": 2, 89 | "no-inner-declarations": 2, 90 | "no-invalid-regexp": 2, 91 | "no-irregular-whitespace": 2, 92 | "no-iterator": 2, 93 | "no-labels": 2, 94 | "no-lonely-if": 2, 95 | "no-loop-func": 2, 96 | "no-mixed-requires": 2, 97 | "no-mixed-spaces-and-tabs": 2, 98 | "no-multi-spaces": 2, 99 | "no-multiple-empty-lines": [ 100 | 2, 101 | { 102 | "max": 1, 103 | "maxEOF": 0, 104 | "maxBOF": 0 105 | } 106 | ], 107 | "no-native-reassign": 2, 108 | "no-negated-in-lhs": 2, 109 | "no-new-func": 2, 110 | "no-new-object": 2, 111 | "no-new-require": 2, 112 | "no-new-wrappers": 2, 113 | "no-obj-calls": 2, 114 | "no-octal": 2, 115 | "no-path-concat": 2, 116 | "no-plusplus": 0, 117 | "no-proto": 2, 118 | "no-redeclare": 2, 119 | "no-return-assign": 2, 120 | "no-self-compare": 2, 121 | "no-sequences": 2, 122 | "no-shadow": 2, 123 | "no-spaced-func": 2, 124 | "no-sparse-arrays": 2, 125 | "no-throw-literal": 2, 126 | "no-trailing-spaces": 2, 127 | "no-undef": 2, 128 | "no-undef-init": 2, 129 | "no-undefined": 2, 130 | "no-underscore-dangle": 0, 131 | "no-unreachable": 2, 132 | "no-unused-expressions": 2, 133 | "no-unused-vars": [ 134 | 2, 135 | { 136 | "vars": "all", 137 | "args": "after-used" 138 | } 139 | ], 140 | "no-use-before-define": [2, "nofunc"], 141 | "no-var": 2, 142 | "no-warning-comments": [ 143 | 2, 144 | { 145 | "terms": ["todo", "fixme"] 146 | } 147 | ], 148 | "object-curly-spacing": [2, "always"], 149 | "padded-blocks": [2, "never"], 150 | "quote-props": [0, "as-needed"], 151 | "quotes": [ 152 | 2, 153 | "single", 154 | { 155 | "avoidEscape": true, 156 | "allowTemplateLiterals": false 157 | } 158 | ], 159 | "radix": 0, 160 | "semi": [2, "never"], 161 | "semi-spacing": [ 162 | 2, 163 | { 164 | "before": false, 165 | "after": true 166 | } 167 | ], 168 | "space-before-blocks": [2, "always"], 169 | "space-before-function-paren": [ 170 | 2, 171 | { 172 | "anonymous": "never", 173 | "named": "never", 174 | "asyncArrow": "always" 175 | } 176 | ], 177 | "space-in-parens": [2, "never"], 178 | "space-infix-ops": 2, 179 | "space-unary-ops": [ 180 | 2, 181 | { 182 | "words": true, 183 | "nonwords": false 184 | } 185 | ], 186 | "strict": [2, "never"], 187 | "use-isnan": 2, 188 | "valid-typeof": 2, 189 | "vars-on-top": 0, 190 | "wrap-iife": [2, "inside"], 191 | "yoda": 0, 192 | "import/extensions": [ 193 | 2, 194 | "never", 195 | { 196 | "css": "always", 197 | "svg": "always", 198 | "json": "always" 199 | } 200 | ] 201 | }, 202 | "globals": { 203 | "require": true, 204 | "jasmine": true, 205 | "expect": true, 206 | "describe": true, 207 | "it": true, 208 | "beforeEach": true, 209 | "afterEach": true, 210 | "spyOn": true, 211 | "$": true, 212 | "SharedWorkerGlobalScope": true 213 | }, 214 | "env": { 215 | "browser": true, 216 | "node": true, 217 | "es6": true, 218 | "jest": true 219 | }, 220 | "plugins": ["import"] 221 | } 222 | -------------------------------------------------------------------------------- /packages/worker/src/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | PONG_COMMAND, 3 | SUBSCRIBE_TO_CHANNEL, 4 | UNSUBSCRIBE_FROM_CHANNEL, 5 | VISIBILITY_SHOW_COMMAND, 6 | VISIBILITY_HIDDEN_COMMAND, 7 | WEBSOCKET_PERFORM_COMMAND, 8 | WORKER_MSG_ERROR_COMMAND, 9 | ALL_COMMANDS 10 | } from './../../../shared/constants' 11 | import { addPortForStore, updatePortPongTime, recurrentPortsChecks } from './workerPorts' 12 | import { loadCableApiWrapper } from './workerCable' 13 | 14 | const DEFAULT_OPTIONS = { 15 | cableType: 'actioncable', // anycable, actioncable 16 | cableLibrary: null, // library require 17 | closeWebsocketWithoutChannels: true, // close websocket if no active channels 18 | handleCustomWebCommand: null // custom handler web command to worker 19 | } 20 | 21 | const isSharedWorker = 22 | self && typeof SharedWorkerGlobalScope !== 'undefined' && self instanceof SharedWorkerGlobalScope 23 | 24 | let cableAPI = null 25 | let cableOptions = {} 26 | let queueChannels = [] 27 | 28 | const addChannelInQueue = (channel = {}) => { 29 | queueChannels = [...queueChannels, channel] 30 | } 31 | 32 | const cleanChannelsInQueue = () => { 33 | queueChannels = [] 34 | } 35 | 36 | const activateChannelInQueue = () => { 37 | if (!cableAPI) { 38 | return 39 | } 40 | 41 | queueChannels.forEach((params) => { 42 | cableAPI.subscribeTo(params) 43 | }) 44 | 45 | cleanChannelsInQueue() 46 | return 47 | } 48 | 49 | const subscribeToChannel = ({ id, port }, channelSettings = {}) => { 50 | const params = { 51 | portID: id, 52 | port, 53 | id: channelSettings.id, 54 | channel: channelSettings.channel, 55 | params: channelSettings.params 56 | } 57 | 58 | if (!cableAPI || cableAPI.isDisconnected()) { 59 | addChannelInQueue(params) 60 | 61 | return 62 | } 63 | 64 | cableAPI.subscribeTo(params) 65 | 66 | return 67 | } 68 | 69 | const unsubscribeFromChannel = (portID, id) => { 70 | if (!cableAPI) { 71 | return 72 | } 73 | 74 | cableAPI.unsubscribeFrom(portID, id) 75 | 76 | return 77 | } 78 | 79 | const performInChannel = (portID, id, perform) => { 80 | if (!cableAPI) { 81 | return 82 | } 83 | 84 | cableAPI.performInChannel(portID, id, perform) 85 | 86 | return 87 | } 88 | 89 | const resumeChannelsForPort = ({ id, port }) => { 90 | if (!cableAPI || cableAPI.isDisconnected()) { 91 | return 92 | } 93 | 94 | cableAPI.resumeChannels({ id, port }) 95 | 96 | return 97 | } 98 | 99 | const pauseChannelsForPort = ({ id, port }) => { 100 | if (!cableAPI || cableAPI.isDisconnected()) { 101 | return 102 | } 103 | 104 | cableAPI.pauseChannels({ id, port }) 105 | 106 | return 107 | } 108 | 109 | const captureWorkerError = ({ port, event }) => { 110 | port.postMessage({ command: WORKER_MSG_ERROR_COMMAND, event: event.toString() }) 111 | } 112 | 113 | const handleWorkerMessages = ({ id, event, port }) => { 114 | const message = event?.data || {} 115 | 116 | switch (message?.command) { 117 | case PONG_COMMAND: { 118 | // update port lifetime 119 | updatePortPongTime(id) 120 | return 121 | } 122 | case SUBSCRIBE_TO_CHANNEL: { 123 | subscribeToChannel({ id, port }, message?.subscription) 124 | return 125 | } 126 | case UNSUBSCRIBE_FROM_CHANNEL: { 127 | unsubscribeFromChannel(id, message?.subscription?.id) 128 | return 129 | } 130 | case WEBSOCKET_PERFORM_COMMAND: { 131 | performInChannel(id, message?.subscription?.id, message?.perform) 132 | return 133 | } 134 | case VISIBILITY_SHOW_COMMAND: { 135 | resumeChannelsForPort({ id, port }) 136 | return 137 | } 138 | case VISIBILITY_HIDDEN_COMMAND: { 139 | pauseChannelsForPort({ id, port }) 140 | return 141 | } 142 | default: { 143 | // custom web commands 144 | if (cableOptions.handleCustomWebCommand) { 145 | const responseFn = (command, data = {}) => { 146 | if (ALL_COMMANDS.indexOf(command) >= 0) { 147 | throw new Error(`Command ${command} busy by cable-shared-worker`) 148 | } 149 | port.postMessage({ command, data }) 150 | } 151 | cableOptions.handleCustomWebCommand(message?.command, message?.data, responseFn) 152 | } 153 | } 154 | } 155 | } 156 | 157 | const disconnectSubscriptionsFromPort = ({ id }) => { 158 | if (!cableAPI) { 159 | return 160 | } 161 | 162 | cableAPI.unsubscribeAllFromPort(id) 163 | 164 | return 165 | } 166 | 167 | const registerPort = (port) => { 168 | const id = addPortForStore(port) 169 | port.addEventListener('message', (event) => handleWorkerMessages({ port, id, event })) 170 | port.addEventListener('messageerror', (event) => captureWorkerError({ port, id, event })) 171 | } 172 | 173 | if (isSharedWorker) { 174 | // Event handler called when a tab tries to connect to this worker. 175 | self.addEventListener('connect', (e) => { 176 | // Get the MessagePort from the event. This will be the 177 | // communication channel between SharedWorker and the Tab 178 | const port = e.ports[0] 179 | registerPort(port) 180 | port.start() // Required when using addEventListener. Otherwise called implicitly by onmessage setter. 181 | }) 182 | // checking for dead ports in shared worker; for web worker closed tab terminate worker 183 | recurrentPortsChecks(disconnectSubscriptionsFromPort) 184 | } else { 185 | // in dedicated worker we use self as port (and do not check if it is alive - it will die together with page) 186 | registerPort(self) 187 | } 188 | 189 | const afterConnect = () => { 190 | activateChannelInQueue() 191 | } 192 | 193 | const afterDisconnect = () => { 194 | cleanChannelsInQueue() 195 | } 196 | 197 | const initCableLibrary = (options = {}) => { 198 | if (cableAPI) { 199 | return cableAPI 200 | } 201 | cableOptions = { ...DEFAULT_OPTIONS, ...options } 202 | const { cableType, cableLibrary, ...restOptions } = cableOptions 203 | cableAPI = loadCableApiWrapper(cableType, cableLibrary, restOptions, { 204 | connect: afterConnect, 205 | disconnect: afterDisconnect 206 | }) 207 | return cableAPI 208 | } 209 | 210 | export { initCableLibrary } 211 | -------------------------------------------------------------------------------- /packages/web/src/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | PING_COMMAND, 3 | PONG_COMMAND, 4 | SUBSCRIBE_TO_CHANNEL, 5 | UNSUBSCRIBE_FROM_CHANNEL, 6 | WEBSOCKET_PERFORM_COMMAND, 7 | WEBSOCKET_MESSAGE_COMMAND, 8 | VISIBILITY_SHOW_COMMAND, 9 | VISIBILITY_HIDDEN_COMMAND, 10 | WORKER_MSG_ERROR_COMMAND, 11 | ALL_COMMANDS 12 | } from './../../../shared/constants' 13 | import { uuid } from './../../../shared/uuid' 14 | import { activateVisibilityAPI } from './visibility' 15 | 16 | const DEFAULT_OPTIONS = { 17 | workerOptions: { 18 | name: 'CableSW' 19 | }, 20 | onError: (error) => { 21 | // eslint-disable-next-line no-console 22 | console.error(error) 23 | }, 24 | fallbackToWebWorker: true, // switch to web worker on safari 25 | handleCustomWorkerCommand: null, // custom handler worker command to web 26 | visibilityTimeout: 0, // 0 is disabled 27 | onVisibilityChange: null // subscribe for visibility 28 | } 29 | 30 | const TYPE_SHARED_WORKER = 'shared' 31 | const TYPE_WEB_WORKER = 'web' 32 | 33 | const isWebWorkerAvailable = !!window.Worker 34 | const isSharedWorkerAvailable = !!window.SharedWorker 35 | const isWorkersAvailable = isSharedWorkerAvailable || isWebWorkerAvailable 36 | 37 | let workerPort = null 38 | let cableReceiveMapping = {} 39 | let visibilityDeactivation = null 40 | 41 | const triggerSubscriptionForChannel = (id, data) => { 42 | if (cableReceiveMapping[id]) { 43 | cableReceiveMapping[id](data) 44 | } 45 | } 46 | 47 | const handleWorkerMessages = ({ event, options = {} }) => { 48 | const message = event?.data || {} 49 | 50 | switch (message?.command) { 51 | case PING_COMMAND: { 52 | // always response on ping 53 | workerPort.postMessage({ command: PONG_COMMAND }) 54 | return 55 | } 56 | case WEBSOCKET_MESSAGE_COMMAND: { 57 | triggerSubscriptionForChannel(message?.id, message?.data) 58 | return 59 | } 60 | case WORKER_MSG_ERROR_COMMAND: { 61 | // get error from worker 62 | options.onError(message.event) 63 | return 64 | } 65 | default: { 66 | // custom worker commands 67 | if (options.handleCustomWorkerCommand) { 68 | options.handleCustomWorkerCommand(message?.command, message?.data) 69 | } 70 | } 71 | } 72 | } 73 | 74 | const startWorker = ({ 75 | resolve, 76 | reject, 77 | workerUrl, 78 | type = TYPE_SHARED_WORKER, 79 | options = {}, 80 | workerOptions = {} 81 | }) => { 82 | try { 83 | if (type === TYPE_SHARED_WORKER) { 84 | const worker = new window.SharedWorker(workerUrl, workerOptions) 85 | worker.addEventListener('error', (event) => options.onError?.(event)) 86 | workerPort = worker.port 87 | } else { 88 | workerPort = new window.Worker(workerUrl, workerOptions) 89 | workerPort.addEventListener('error', (event) => options.onError?.(event)) 90 | } 91 | } catch (e) { 92 | return reject(e) 93 | } 94 | if (!workerPort) { 95 | return reject('Error to create worker') 96 | } 97 | 98 | workerPort.addEventListener('message', (event) => handleWorkerMessages({ event, options })) 99 | workerPort.addEventListener('messageerror', (event) => options.onError?.(event)) 100 | 101 | if (type === TYPE_SHARED_WORKER) { 102 | workerPort.start() // we need start port only for shared worker 103 | } 104 | 105 | if (options?.visibilityTimeout && options.visibilityTimeout > 0) { 106 | visibilityDeactivation = activateVisibilityAPI({ 107 | timeout: options.visibilityTimeout, 108 | visible: (isChannelsWasPaused) => { 109 | if (isChannelsWasPaused) { 110 | workerPort.postMessage({ command: VISIBILITY_SHOW_COMMAND }) 111 | } 112 | if (options.onVisibilityChange) { 113 | options.onVisibilityChange(true, isChannelsWasPaused) 114 | } 115 | }, 116 | hidden: (isChannelsWasPaused) => { 117 | if (isChannelsWasPaused) { 118 | workerPort.postMessage({ command: VISIBILITY_HIDDEN_COMMAND }) 119 | } 120 | if (options.onVisibilityChange) { 121 | options.onVisibilityChange(false, isChannelsWasPaused) 122 | } 123 | } 124 | }) 125 | } 126 | 127 | return resolve({ 128 | sendCommand: (command, data = {}) => { 129 | if (ALL_COMMANDS.indexOf(command) >= 0) { 130 | throw new Error(`Command ${command} busy by cable-shared-worker`) 131 | } 132 | workerPort.postMessage({ command, data }) 133 | } 134 | }) 135 | } 136 | 137 | const initWorker = (workerUrl, options = {}) => 138 | new Promise((resolve, reject) => { 139 | if (workerPort) { 140 | return resolve() 141 | } 142 | 143 | if (!workerUrl) { 144 | return reject('Need to provide worker url') 145 | } 146 | 147 | const { workerOptions, ...restOptions } = options 148 | 149 | const mergedOptions = { 150 | ...DEFAULT_OPTIONS, 151 | ...restOptions 152 | } 153 | 154 | const workerArgs = { 155 | resolve, 156 | reject, 157 | options: mergedOptions, 158 | workerUrl, 159 | workerOptions: { 160 | ...(DEFAULT_OPTIONS.workerOptions || {}), 161 | ...(workerOptions || {}) 162 | } 163 | } 164 | 165 | if (isSharedWorkerAvailable) { 166 | return startWorker({ 167 | ...workerArgs, 168 | type: TYPE_SHARED_WORKER 169 | }) 170 | } 171 | 172 | if (!isSharedWorkerAvailable && !mergedOptions.fallbackToWebWorker) { 173 | return reject('Shared worker not available') 174 | } 175 | 176 | if (isWebWorkerAvailable) { 177 | return startWorker({ 178 | ...workerArgs, 179 | type: TYPE_WEB_WORKER 180 | }) 181 | } 182 | 183 | return reject('Shared worker and Web worker not available') 184 | }) 185 | 186 | const createChannel = (channel, params = {}, onReceiveMessage = () => ({})) => 187 | new Promise((resolve, reject) => { 188 | if (!workerPort) { 189 | return reject('You need create worker by initWorker method before call createChannel method') 190 | } 191 | 192 | const id = uuid() 193 | cableReceiveMapping = { 194 | ...cableReceiveMapping, 195 | [id]: onReceiveMessage 196 | } 197 | workerPort.postMessage({ 198 | command: SUBSCRIBE_TO_CHANNEL, 199 | subscription: { 200 | id, 201 | channel, 202 | params 203 | } 204 | }) 205 | 206 | return resolve({ 207 | perform: (performAction, performParams = {}) => { 208 | if (workerPort) { 209 | workerPort.postMessage({ 210 | command: WEBSOCKET_PERFORM_COMMAND, 211 | subscription: { id }, 212 | perform: { 213 | action: performAction, 214 | params: performParams 215 | } 216 | }) 217 | } 218 | }, 219 | unsubscribe: () => { 220 | cableReceiveMapping = Object.keys(cableReceiveMapping).reduce((agg, key) => { 221 | if (key === id) { 222 | return agg 223 | } 224 | return { 225 | ...agg, 226 | [key]: cableReceiveMapping[key] 227 | } 228 | }, {}) 229 | 230 | if (workerPort) { 231 | workerPort.postMessage({ 232 | command: UNSUBSCRIBE_FROM_CHANNEL, 233 | subscription: { id } 234 | }) 235 | } 236 | } 237 | }) 238 | }) 239 | 240 | const closeWorker = () => 241 | new Promise((resolve) => { 242 | if (visibilityDeactivation) { 243 | visibilityDeactivation() 244 | visibilityDeactivation = null 245 | } 246 | if (workerPort) { 247 | if (workerPort.close) { 248 | workerPort.close() // close shared worker port 249 | } else if (workerPort.terminate) { 250 | workerPort.terminate() // close web worker port 251 | } 252 | workerPort = null 253 | } 254 | resolve() 255 | }) 256 | 257 | export { 258 | isWorkersAvailable, 259 | isSharedWorkerAvailable, 260 | isWebWorkerAvailable, 261 | initWorker, 262 | createChannel, 263 | closeWorker 264 | } 265 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cable-shared-worker (CableSW) - ActionCable and AnyCable Shared Worker support [![Test/Build/Deploy](https://github.com/le0pard/cable-shared-worker/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/le0pard/cable-shared-worker/actions/workflows/release.yml) 2 | 3 | ![schema](https://user-images.githubusercontent.com/98444/146681981-2e87a26e-9a5b-4109-9b05-73c1329b3ccc.jpg) 4 | 5 | Cable-shared-worker is running ActionCable or AnyCable client in a Shared Worker allows you to share a single websocket connection for multiple browser windows and tabs. 6 | 7 | ## Motivation 8 | 9 | - It's more efficient to have a single websocket connection 10 | - Page refreshes and new tabs already have a websocket connection, so connection setup time is zero 11 | - The websocket connection runs in a separate thread/process so your UI is 'faster' 12 | - Cordination of event notifications is simpler as updates have a single source 13 | - Close connection for non active (on background) tabs (by [Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API)) 14 | - It's the cool stuff... 15 | 16 | ## Install 17 | 18 | ```bash 19 | npm install @cable-shared-worker/web @cable-shared-worker/worker 20 | # or 21 | yarn add @cable-shared-worker/web @cable-shared-worker/worker 22 | ``` 23 | 24 | Both packages should be the same version. 25 | 26 | ## Web 27 | 28 | You need to initialize worker inside your JS file: 29 | 30 | ```js 31 | import {initWorker} from '@cable-shared-worker/web' 32 | 33 | await initWorker('/worker.js') 34 | ``` 35 | 36 | Second argument accept different options: 37 | 38 | ```js 39 | await initWorker( 40 | '/worker.js', 41 | { 42 | workerOptions: { // worker options - more info https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker/SharedWorker 43 | name: 'CableSW' 44 | }, 45 | onError: (error) => console.error(error), // subscribe to worker errors 46 | fallbackToWebWorker: true, // switch to web worker on safari 47 | visibilityTimeout: 0, // timeout for visibility API, before close channels; 0 is disabled 48 | onVisibilityChange: () => ({}) // subscribe for visibility changes 49 | } 50 | ) 51 | ``` 52 | 53 | After this you can start subscription channel: 54 | 55 | ```js 56 | import {createChannel} from '@cable-shared-worker/web' 57 | 58 | // Subscribe to the server channel via the client 59 | const channel = await createChannel('ChatChannel', {roomId: 42}, (data) => { 60 | console.log(data) 61 | }) 62 | 63 | // call `ChatChannel#speak(data)` on the server 64 | channel.perform('speak', {msg: 'Hello'}) 65 | 66 | // Unsubscribe from the channel 67 | channel.unsubscribe() 68 | ``` 69 | 70 | You can manually close worker (for shared worker this will only close current tab connection, but not worker itself): 71 | 72 | ```js 73 | import {closeWorker} from '@cable-shared-worker/web' 74 | 75 | // close tab connection to worker 76 | closeWorker() 77 | ``` 78 | 79 | This helpers may help to get info what kind of workers available in browser: 80 | 81 | ```js 82 | import { 83 | isWorkersAvailable, 84 | isSharedWorkerAvailable, 85 | isWebWorkerAvailable 86 | } from '@cable-shared-worker/web' 87 | 88 | isWorkersAvailable // return true, if Shared or Web worker available 89 | isSharedWorkerAvailable // return true, if Shared worker available 90 | isWebWorkerAvailable // return true, if Web worker available 91 | ``` 92 | 93 | ### Visibility API 94 | 95 | You can use [Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API) to detect, that user move tab on background and close websocket channels. Shared Worker websocket connection can be closed, if no active channels (behaviour controlled by option `closeWebsocketWithoutChannels` in worker component). 96 | 97 | ```js 98 | import {initWorker} from '@cable-shared-worker/web' 99 | 100 | initWorker( 101 | '/worker.js', 102 | { 103 | visibilityTimeout: 60, // 60 seconds wait before start close channels, default 0 is disable this functionality 104 | onVisibilityChange: (isVisible, isChannelsWasPaused) => { // callback for visibility changes 105 | if (isVisible && isChannelsWasPaused) { 106 | // this condition can be used to fetch data changes, because channels was closed due to tab on background 107 | } 108 | } 109 | } 110 | ) 111 | ``` 112 | 113 | ## Worker 114 | 115 | In worker script (in example `/worker.js`) you need initialize websocket connection. 116 | 117 | For actioncable you need installed [@rails/actioncable](https://www.npmjs.com/package/@rails/actioncable) package: 118 | 119 | ```js 120 | import * as actioncableLibrary from '@rails/actioncable' 121 | import {initCableLibrary} from '@cable-shared-worker/worker' 122 | 123 | // init actioncable library 124 | const api = initCableLibrary({ 125 | cableType: 'actioncable', 126 | cableLibrary: actioncableLibrary 127 | }) 128 | 129 | // connect by websocket url 130 | api.createCable(WebSocketURL) 131 | ``` 132 | 133 | For anycable you need install [@anycable/web](https://www.npmjs.com/package/@anycable/web) package: 134 | 135 | ```js 136 | import * as anycableLibrary from '@anycable/web' 137 | import {initCableLibrary} from '@cable-shared-worker/worker' 138 | 139 | // init anycable library 140 | const api = initCableLibrary({ 141 | cableType: 'anycable', 142 | cableLibrary: anycableLibrary 143 | }) 144 | 145 | // connect by websocket url 146 | api.createCable(WebSocketURL) 147 | ``` 148 | 149 | You can also use Msgpack and Protobuf protocols supported by [AnyCable Pro](https://anycable.io/#pro) (you must install the corresponding encoder package yourself): 150 | 151 | ```js 152 | import * as anycableLibrary from '@anycable/web' 153 | import {MsgpackEncoder} from '@anycable/msgpack-encoder' 154 | import {initCableLibrary} from '@cable-shared-worker/worker' 155 | 156 | const api = initCableLibrary({ 157 | cableType: 'anycable', 158 | cableLibrary: anycableLibrary 159 | }) 160 | 161 | api.createCable( 162 | webSocketURL, 163 | { 164 | protocol: 'actioncable-v1-msgpack', 165 | encoder: new MsgpackEncoder() 166 | } 167 | ) 168 | 169 | // or for protobuf 170 | import * as anycableLibrary from '@anycable/web' 171 | import {ProtobufEncoder} from '@anycable/protobuf-encoder' 172 | import {initCableLibrary} from '@cable-shared-worker/worker' 173 | 174 | const api = initCableLibrary({ 175 | cableType: 'anycable', 176 | cableLibrary: anycableLibrary 177 | }) 178 | 179 | api.createCable( 180 | webSocketURL, 181 | { 182 | protocol: 'actioncable-v1-protobuf', 183 | encoder: new ProtobufEncoder() 184 | } 185 | ) 186 | ``` 187 | 188 | If you need manually close websocket connection, you can use `destroyCable` method: 189 | 190 | ```js 191 | import * as actioncableLibrary from '@rails/actioncable' 192 | import {initCableLibrary} from '@cable-shared-worker/worker' 193 | 194 | const api = initCableLibrary({ 195 | cableType: 'actioncable', 196 | cableLibrary: actioncableLibrary 197 | }) 198 | 199 | api.createCable(WebSocketURL) 200 | 201 | // later in code 202 | 203 | api.destroyCable() 204 | ``` 205 | 206 | Method `initCableLibrary` accept additional option `closeWebsocketWithoutChannels`: 207 | 208 | ```js 209 | const api = initCableLibrary({ 210 | cableType: 'actioncable', 211 | cableLibrary: actioncableLibrary, 212 | // if true (default), worker will close websocket connection, if have zero active channels 213 | // example: all tabs on the background send a signal to close all channels by visibility API timeout 214 | closeWebsocketWithoutChannels: false 215 | }) 216 | ``` 217 | 218 | ## Custom communication between window and worker 219 | 220 | You can use cable-shared-worker for custom communication between window and worker. In window you can use method `sendCommand` to send custom command to worker: 221 | 222 | ```js 223 | import {initWorker} from '@cable-shared-worker/web' 224 | 225 | const worker = await initWorker('/worker.js') 226 | 227 | worker.sendCommand('WINDOW_CUSTOM_COMMAND', {data: 'example'}) 228 | ``` 229 | 230 | On worker side you need define `handleCustomWebCommand` function. First argument will be custom command (in example `WINDOW_CUSTOM_COMMAND`), second one - command data (in example `{data: 'example'}`), third one - response function, which can send response command to window: 231 | 232 | ```js 233 | import * as actioncableLibrary from '@rails/actioncable' 234 | import {initCableLibrary} from '@cable-shared-worker/worker' 235 | 236 | const api = initCableLibrary({ 237 | cableType: 'actioncable', 238 | cableLibrary: actioncableLibrary, 239 | handleCustomWebCommand: (command, data, responseFunction) => { 240 | responseFunction('WORKER_CUSTOM_COMMAND', {another: 'data'}) 241 | } 242 | }) 243 | ``` 244 | 245 | To handle custom commands from worker in window, you need provide `handleCustomWorkerCommand` method in `initWorker`: 246 | 247 | ```js 248 | import {initWorker} from '@cable-shared-worker/web' 249 | 250 | const worker = await initWorker( 251 | '/worker.js', 252 | { 253 | handleCustomWorkerCommand: (command, data) => { 254 | console.log('worker response', command, data) 255 | } 256 | } 257 | ) 258 | 259 | worker.sendCommand('WINDOW_CUSTOM_COMMAND', {data: 'example'}) 260 | ``` 261 | 262 | Note: You cannot [send commands](https://github.com/le0pard/cable-shared-worker/blob/main/shared/constants.js), that the package uses itself for communication. 263 | 264 | ## Browser Support 265 | 266 | Supported modern browsers, that support Shared Worker (IE, Opera Mini not supported). 267 | 268 | Safari supports [Shared Worker](https://caniuse.com/sharedworkers) only from version 16.0 (Sep, 2022). For older version, package will switch to Web Worker, which cannot share connection between tabs. You can disable fallback to Web Worker by `fallbackToWebWorker: false` (or use `isSharedWorkerAvailable` for own logic). 269 | 270 | ## Development 271 | 272 | ```bash 273 | $ yarn # install all dependencies 274 | $ yarn dev # run development build with watch functionality 275 | $ yarn build # run production build 276 | $ yarn lint # run eslint checks 277 | $ yarn test # run tests 278 | ``` 279 | 280 | 281 | -------------------------------------------------------------------------------- /packages/worker/src/cableWrapper.js: -------------------------------------------------------------------------------- 1 | import { ACTIONCABLE_TYPE, WEBSOCKET_MESSAGE_COMMAND } from './../../../shared/constants' 2 | 3 | const UNSUBSCRIBE_CHECK_TIMEOUT = 300 // give time to unsubscribe from channels 4 | 5 | const STATUS_CONNECTED = 'connected' 6 | const STATUS_PAUSED = 'paused' 7 | const STATUS_DISCONNECTED = 'disconnected' 8 | 9 | export const initCableWrapper = (apiType = ACTIONCABLE_TYPE, api, options = {}, hooks = {}) => { 10 | let websocketConnection = null 11 | let websocketConnectionStatus = STATUS_DISCONNECTED 12 | let portReceiverMapping = {} 13 | 14 | const isActioncableAPI = apiType === ACTIONCABLE_TYPE 15 | 16 | const isActive = () => !!websocketConnection && websocketConnectionStatus === STATUS_CONNECTED 17 | const isPaused = () => !!websocketConnection && websocketConnectionStatus === STATUS_PAUSED 18 | const isDisconnected = () => !websocketConnection 19 | 20 | const pauseConnection = () => { 21 | if (websocketConnection) { 22 | websocketConnection.disconnect() 23 | } 24 | websocketConnectionStatus = STATUS_PAUSED 25 | } 26 | 27 | const resumeConnection = () => { 28 | if (websocketConnection) { 29 | websocketConnection.connect() 30 | } 31 | websocketConnectionStatus = STATUS_CONNECTED 32 | } 33 | 34 | const resumeConnectionIfNeeded = () => { 35 | if (options?.closeWebsocketWithoutChannels && isPaused()) { 36 | resumeConnection() 37 | } 38 | } 39 | 40 | const pauseConnectionIfNeeded = () => { 41 | if (options?.closeWebsocketWithoutChannels && isActive()) { 42 | const haveActiveChannels = Object.keys(portReceiverMapping).some((portKey) => { 43 | return Object.keys(portReceiverMapping[portKey]).some((keySub) => { 44 | return ( 45 | portReceiverMapping[portKey][keySub] && !!portReceiverMapping[portKey][keySub]?.channel 46 | ) 47 | }) 48 | }) 49 | 50 | if (!haveActiveChannels) { 51 | pauseConnection() 52 | } 53 | } 54 | } 55 | 56 | return { 57 | isActive, 58 | isPaused, 59 | isDisconnected, 60 | createCable: (wUrl, wOptions = {}) => 61 | new Promise((resolve) => { 62 | if (websocketConnection) { 63 | return resolve() 64 | } 65 | websocketConnection = isActioncableAPI 66 | ? api.createConsumer(wUrl) 67 | : api.createCable(wUrl, wOptions) 68 | websocketConnectionStatus = STATUS_CONNECTED 69 | if (hooks?.connect) { 70 | hooks.connect() 71 | } 72 | return resolve() 73 | }), 74 | subscribeTo: ({ port, portID, id, channel, params = {} }) => { 75 | resumeConnectionIfNeeded() 76 | 77 | const channelData = { channel, params } 78 | 79 | const addSubscription = (subscriptionChannel) => { 80 | portReceiverMapping = { 81 | ...portReceiverMapping, 82 | [portID]: { 83 | ...(portReceiverMapping[portID] || {}), 84 | [id]: { 85 | channel: subscriptionChannel, 86 | channelData 87 | } 88 | } 89 | } 90 | } 91 | 92 | if (isActioncableAPI) { 93 | const subscriptionChannel = websocketConnection.subscriptions.create( 94 | { 95 | ...params, 96 | channel 97 | }, 98 | { 99 | received: (data) => { 100 | port.postMessage({ command: WEBSOCKET_MESSAGE_COMMAND, data, id }) 101 | } 102 | } 103 | ) 104 | 105 | return addSubscription(subscriptionChannel) 106 | } else { 107 | return websocketConnection.subscribeTo(channel, params).then((subscriptionChannel) => { 108 | subscriptionChannel.on('message', (data) => { 109 | port.postMessage({ command: WEBSOCKET_MESSAGE_COMMAND, data, id }) 110 | }) 111 | 112 | return addSubscription(subscriptionChannel) 113 | }) 114 | } 115 | }, 116 | performInChannel: (portID, id, { action, params = {} }) => { 117 | if (portReceiverMapping[portID] && portReceiverMapping[portID][id]?.channel) { 118 | const { channel } = portReceiverMapping[portID][id] 119 | channel.perform(action, params) 120 | } 121 | }, 122 | unsubscribeFrom: (portID, id) => { 123 | if (portReceiverMapping[portID] && portReceiverMapping[portID][id]?.channel) { 124 | const { channel } = portReceiverMapping[portID][id] 125 | if (isActioncableAPI) { 126 | channel.unsubscribe() 127 | } else { 128 | channel.disconnect() 129 | } 130 | 131 | portReceiverMapping = Object.keys(portReceiverMapping).reduce((aggPorts, portKey) => { 132 | const subsMap = Object.keys(portReceiverMapping[portKey]).reduce((aggSub, keySub) => { 133 | if (portKey === portID && keySub === id) { 134 | return aggSub 135 | } 136 | return { 137 | ...aggSub, 138 | [keySub]: portReceiverMapping[portKey][keySub] 139 | } 140 | }, {}) 141 | 142 | if (Object.keys(subsMap).length > 0) { 143 | return { 144 | ...aggPorts, 145 | [portKey]: subsMap 146 | } 147 | } 148 | 149 | return aggPorts 150 | }, {}) 151 | 152 | setTimeout(() => pauseConnectionIfNeeded(), UNSUBSCRIBE_CHECK_TIMEOUT) 153 | } 154 | }, 155 | unsubscribeAllFromPort: (portID) => { 156 | if (portReceiverMapping[portID]) { 157 | portReceiverMapping = Object.keys(portReceiverMapping).reduce((aggPorts, portKey) => { 158 | if (portKey === portID) { 159 | Object.keys(portReceiverMapping[portKey]).forEach((keySub) => { 160 | if (portReceiverMapping[portKey][keySub]?.channel) { 161 | const { channel } = portReceiverMapping[portKey][keySub] 162 | if (isActioncableAPI) { 163 | channel.unsubscribe() 164 | } else { 165 | channel.disconnect() 166 | } 167 | } 168 | }) 169 | 170 | return aggPorts 171 | } 172 | 173 | return { 174 | ...aggPorts, 175 | [portKey]: portReceiverMapping[portKey] 176 | } 177 | }, {}) 178 | 179 | setTimeout(() => pauseConnectionIfNeeded(), UNSUBSCRIBE_CHECK_TIMEOUT) 180 | } 181 | }, 182 | resumeChannels: ({ id, port }) => { 183 | if (portReceiverMapping[id]) { 184 | resumeConnectionIfNeeded() 185 | 186 | const haveInactiveChannels = Object.keys(portReceiverMapping[id]).some( 187 | (keySub) => 188 | !portReceiverMapping[id][keySub]?.channel && 189 | !!portReceiverMapping[id][keySub]?.channelData 190 | ) 191 | 192 | if (!haveInactiveChannels) { 193 | return 194 | } 195 | 196 | if (isActioncableAPI) { 197 | portReceiverMapping = { 198 | ...portReceiverMapping, 199 | [id]: Object.keys(portReceiverMapping[id]).reduce((aggSub, keySub) => { 200 | if ( 201 | portReceiverMapping[id][keySub]?.channelData && 202 | !portReceiverMapping[id][keySub]?.channel 203 | ) { 204 | const { channel, params } = portReceiverMapping[id][keySub].channelData 205 | const subscriptionChannel = websocketConnection.subscriptions.create( 206 | { 207 | ...params, 208 | channel 209 | }, 210 | { 211 | received: (data) => { 212 | port.postMessage({ command: WEBSOCKET_MESSAGE_COMMAND, data, id: keySub }) 213 | } 214 | } 215 | ) 216 | return { 217 | ...aggSub, 218 | [keySub]: { 219 | ...portReceiverMapping[id][keySub], 220 | channel: subscriptionChannel 221 | } 222 | } 223 | } else if (portReceiverMapping[id][keySub]?.channel) { 224 | return { 225 | ...aggSub, 226 | [keySub]: portReceiverMapping[id][keySub] 227 | } 228 | } 229 | return aggSub 230 | }, {}) 231 | } 232 | } else { 233 | Promise.all( 234 | Object.keys(portReceiverMapping[id]).map((keySub) => { 235 | if ( 236 | portReceiverMapping[id][keySub]?.channelData && 237 | !portReceiverMapping[id][keySub]?.channel 238 | ) { 239 | const { channelData } = portReceiverMapping[id][keySub] 240 | const { channel, params } = channelData 241 | return websocketConnection 242 | .subscribeTo(channel, params) 243 | .then((subscriptionChannel) => { 244 | subscriptionChannel.on('message', (data) => { 245 | port.postMessage({ command: WEBSOCKET_MESSAGE_COMMAND, data, id: keySub }) 246 | }) 247 | 248 | return [ 249 | keySub, 250 | { 251 | channel: subscriptionChannel, 252 | channelData 253 | } 254 | ] 255 | }) 256 | } 257 | if (portReceiverMapping[id][keySub]?.channel) { 258 | return Promise.resolve([keySub, portReceiverMapping[id][keySub]]) 259 | } 260 | return Promise.resolve(null) 261 | }) 262 | ).then((values) => { 263 | const restoredChannels = values.filter(Boolean).reduce((agg, [keySub, data]) => { 264 | return { 265 | ...agg, 266 | [keySub]: data 267 | } 268 | }, {}) 269 | 270 | portReceiverMapping = { 271 | ...portReceiverMapping, 272 | [id]: restoredChannels 273 | } 274 | }) 275 | } 276 | } 277 | }, 278 | pauseChannels: ({ id }) => { 279 | if (portReceiverMapping[id]) { 280 | portReceiverMapping = { 281 | ...portReceiverMapping, 282 | [id]: Object.keys(portReceiverMapping[id]).reduce((aggSub, keySub) => { 283 | if ( 284 | portReceiverMapping[id][keySub]?.channel && 285 | portReceiverMapping[id][keySub]?.channelData 286 | ) { 287 | const { channel, ...restData } = portReceiverMapping[id][keySub] 288 | if (isActioncableAPI) { 289 | channel.unsubscribe() 290 | } else { 291 | channel.disconnect() 292 | } 293 | 294 | return { 295 | ...aggSub, 296 | [keySub]: restData 297 | } 298 | } 299 | return aggSub 300 | }, {}) 301 | } 302 | 303 | setTimeout(() => pauseConnectionIfNeeded(), UNSUBSCRIBE_CHECK_TIMEOUT) 304 | } 305 | }, 306 | destroyCable: () => 307 | new Promise((resolve) => { 308 | if (websocketConnection) { 309 | websocketConnection.disconnect() 310 | websocketConnection = null 311 | websocketConnectionStatus = STATUS_DISCONNECTED 312 | } 313 | if (hooks?.disconnect) { 314 | hooks.disconnect() 315 | } 316 | return resolve() 317 | }) 318 | } 319 | } 320 | --------------------------------------------------------------------------------