├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ └── nodejs.yml ├── .gitignore ├── .npmignore ├── .nycrc ├── LICENSE ├── README.md ├── browser.ts ├── index.ts ├── package-lock.json ├── package.json ├── src ├── browser.ts ├── buffer.ts ├── common.ts ├── extra.ts ├── index.ts ├── inflate │ ├── brotli.ts │ ├── browser.ts │ └── node.ts ├── tcp.ts └── ws.ts ├── test └── test.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "21:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.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: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '36 1 * * 2' 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' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | branches: '*' 6 | pull_request: 7 | branches : '*' 8 | schedule: 9 | - cron: '0 0 * * *' 10 | jobs: 11 | build: 12 | 13 | runs-on: Ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Use Node.js 18 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: '18' 21 | - name: npm install and test 22 | run: | 23 | npm install 24 | npm test 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.d.ts 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # vuepress build output 72 | .vuepress/dist 73 | 74 | # Serverless directories 75 | .serverless 76 | 77 | # FuseBox cache 78 | .fusebox/ 79 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitattributes 2 | .eslintignore 3 | .eslintrc.js 4 | .travis.yml 5 | .nyc_output/**/* 6 | 7 | examples/ 8 | md/ 9 | 10 | test.js 11 | 12 | **/*.ts 13 | !**/*.d.ts 14 | test/**/* 15 | .github 16 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "extension": [ 3 | ".ts" 4 | ], 5 | "require": [ 6 | "ts-node/register" 7 | ], 8 | "sourceMap": true, 9 | "instrument": true 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 simon3000 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bilibili-live-ws [![npm](https://img.shields.io/npm/v/bilibili-live-ws.svg)](https://www.npmjs.com/package/bilibili-live-ws) ![Node CI](https://github.com/simon300000/bilibili-live-ws/workflows/Node%20CI/badge.svg) 2 | 3 | Bilibili 直播 WebSocket/TCP API 4 | 5 | LiveWS/KeepLiveWS 支持浏览器 *(实验性)* 6 | 7 | 应该支持 bilibili直播开放平台 8 | 9 | 注:如果在浏览器环境遇到问题,可以尝试手动指定引入 `bilibili-live-ws/browser` 10 | 11 | ## API 12 | 13 | ```javascript 14 | const { LiveWS, LiveTCP, KeepLiveWS, KeepLiveTCP } = require('bilibili-live-ws') 15 | const live = new LiveWS(roomid) 16 | // 或 17 | const live = new LiveTCP(roomid) 18 | ``` 19 | 20 | 举个栗子: 21 | 22 | ```javascript 23 | const live = new LiveWS(14275133) 24 | 25 | live.on('open', () => console.log('Connection is established')) 26 | // Connection is established 27 | live.on('live', () => { 28 | live.on('heartbeat', console.log) 29 | // 74185 30 | }) 31 | ``` 32 | 33 | 或者用TCP (新功能): 34 | 35 | ```javascript 36 | const live = new LiveTCP(26283) 37 | 38 | live.on('open', () => console.log('Connection is established')) 39 | // Connection is established 40 | live.on('live', () => { 41 | live.on('heartbeat', console.log) 42 | // 13928 43 | }) 44 | ``` 45 | 46 | > 晚上做梦梦到一个白胡子的老爷爷和我说TCP更节约内存哦! 47 | 48 | ## Class: LiveWS / LiveTCP / KeepLiveWS / KeepLiveTCP 49 | 50 | (Keep)LiveWS 和 (Keep)LiveTCP 的大部分API基本上一样, 区别在本文末尾有注明 51 | 52 | ### new LiveWS(roomid [, { address, protover, key, authBody, uid, buvid }]) 53 | 54 | ### new LiveTCP(roomid [, { host, port, protover, key, authBody, uid, buvid }]) 55 | 56 | - `roomid` 房间号 57 | 58 | 比如 https://live.bilibili.com/14327465 中的 `14327465` 59 | 60 | - `address` 可选, WebSocket连接的地址 61 | 62 | 默认 `'wss://broadcastlv.chat.bilibili.com/sub'` 63 | 64 | - `host` 可选, TCP连接的地址 65 | 66 | 默认 `'broadcastlv.chat.bilibili.com'` 67 | 68 | - `port` 可选, TCP连接的端口 69 | 70 | 默认 `2243` 71 | 72 | - `protover` 可选 (1, 2, 3) 73 | 74 | 默认 `2` 75 | 76 | * 1 (见 [#17](https://github.com/simon300000/bilibili-live-ws/issues/17)) 77 | * 2 (zlib.inflate) 78 | * 3 (brotliDecompress) 79 | 80 | - `uid` 可选, WS握手的 uid [#397](https://github.com/simon300000/bilibili-live-ws/issues/397) 81 | 82 | - `key` 可选, WS握手的 Token [#397](https://github.com/simon300000/bilibili-live-ws/issues/397) 83 | 84 | - `buvid` 可选, WS握手的 Token [#397](https://github.com/simon300000/bilibili-live-ws/issues/397) 85 | 86 | - `authBody` 可选, 可以和 配合使用, 会覆盖掉 `protover` `roomid` `key` `uid` `buvid`. 如果是 `object` 会按照握手包编码,如果是 `string`/`Buffer` 会直接发送 87 | 88 | #### live.on('open') 89 | 90 | WebSocket连接上了 91 | 92 | #### live.on('live') 93 | 94 | 成功登入房间 95 | 96 | #### live.on('heartbeat', (online) => ...) 97 | 98 | 收到服务器心跳包,会在30秒之后自动发送心跳包。 99 | 100 | - `online` 当前人气值 101 | 102 | #### live.on('msg', (data) => ...) 103 | 104 | - `data` 收到信息/弹幕/广播等 105 | 106 | data的例子: (我simon3000送了一个辣条) 107 | 108 | ```javascript 109 | { 110 | cmd: 'SEND_GIFT', 111 | data: { 112 | giftName: '辣条', 113 | num: 1, 114 | uname: 'simon3000', 115 | face: 'http://i1.hdslb.com/bfs/face/c26b9f670b10599ad105e2a7fea4b5f21c0f0bcf.jpg', 116 | guard_level: 0, 117 | rcost: 2318827, 118 | uid: 3499295, 119 | top_list: [], 120 | timestamp: 1555690631, 121 | giftId: 1, 122 | giftType: 0, 123 | action: '喂食', 124 | super: 0, 125 | super_gift_num: 0, 126 | price: 100, 127 | rnd: '1555690616', 128 | newMedal: 0, 129 | newTitle: 0, 130 | medal: [], 131 | title: '', 132 | beatId: '0', 133 | biz_source: 'live', 134 | metadata: '', 135 | remain: 6, 136 | gold: 0, 137 | silver: 0, 138 | eventScore: 0, 139 | eventNum: 0, 140 | smalltv_msg: [], 141 | specialGift: null, 142 | notice_msg: [], 143 | capsule: null, 144 | addFollow: 0, 145 | effect_block: 1, 146 | coin_type: 'silver', 147 | total_coin: 100, 148 | effect: 0, 149 | tag_image: '', 150 | user_count: 0 151 | } 152 | } 153 | ``` 154 | 155 | #### live.on(cmd, (data) => ...) 156 | 157 | 用法如上,只不过只会收到特定cmd的Event。 158 | 159 | 比如 `live.on('DANMU_MSG')` 只会收到弹幕Event,一个弹幕Event的Data例子如下: (我simon3000发了个`233`) 160 | 161 | ```javascript 162 | { 163 | cmd: 'DANMU_MSG', 164 | info: [ 165 | [0, 1, 25, 16777215, 1555692037, 1555690616, 0, 'c5c630b1', 0, 0, 0], 166 | '233', 167 | [3499295, 'simon3000', 0, 0, 0, 10000, 1, ''], 168 | [5, '財布', '神楽めあOfficial', 12235923, 5805790, ''], 169 | [11, 0, 6406234, '>50000'], 170 | ['title-58-1', 'title-58-1'], 171 | 0, 172 | 0, 173 | null, 174 | { ts: 1555692037, ct: '2277D56A' } 175 | ] 176 | } 177 | ``` 178 | 179 | 180 | 181 | #### live.on('close') 182 | 183 | 连接关闭事件 184 | 185 | #### live.on('error', (e) => ...) 186 | 187 | 连接 error 事件,连接同时也会关闭 188 | 189 | #### live.heartbeat() 190 | 191 | 无视30秒时间,直接发送一个心跳包。 192 | 193 | 如果连接正常,会收到来自服务器的心跳包,也就是 `live.on('heartbeat')`,可以用于更新人气值。 194 | 195 | #### live.close() 196 | 197 | 关闭WebSocket/TCP Socket连接。 198 | 199 | #### live.getOnline() 200 | 201 | 立即调用 `live.heartbeat()` 刷新人气数值,并且返回 Promise,resolve 人气刷新后数值 202 | 203 | #### live.on('message') 204 | 205 | WebSocket/TCP收到的Raw Buffer(不推荐直接使用) 206 | 207 | #### live.send(buffer) 208 | 209 | 使用 WebSocket 或者 TCP 发送信息 210 | 211 | ### getConf(roomid) 212 | 213 | 选一个cdn,Resolve host, address 和 key, 可以直接放进(Keep)LiveWS/TCP设置 214 | 215 | ```typescript 216 | const { getConf } = require('bilibili-live-ws') 217 | 218 | getConf(roomid) 219 | // Return 220 | Promise<{ 221 | key: string; 222 | host: string; 223 | address: string; 224 | }> 225 | ``` 226 | 227 | ### getRoomid(short) 228 | 229 | 通过短房间号获取长房间号 230 | 231 | ```typescript 232 | const { getRoomid } = require('bilibili-live-ws') 233 | 234 | getRoomid(255) 235 | // Return Promise: 48743 236 | ``` 237 | 238 |
239 | 240 | ### Keep和无Keep的区别 241 | 242 | KeepLiveWS 和 KeepLiveTCP 有断线重新连接的功能 243 | 244 | #### new KeepLiveWS(roomid [, { address, protover, key }]) 245 | 246 | #### new KeepLiveTCP(roomid [, { host, port, protover, key }]) 247 | 248 | 所有上方的API都是一样的, 只不过会有以下微小的区别 249 | 250 | * 收到error或者close事件的时候并不代表连接关闭, 必须要手动调用`live.close()`来关闭连接 251 | * 内部的连接对象(`LiveWS`/`LiveTCP`)在`live.connection`中 252 | * 内部的连接关闭了之后, 如果不是因为`live.close()`关闭, 会在100毫秒之后自动打开一个新的连接。 253 | * 这个100毫秒可以通过改变`live.interval`来设置 254 | * 内部的boolean, `live.closed`, 用于判断是否是因为`live.close()`关闭。 255 | 256 |
257 | 258 | ### LiveWS 和 LiveTCP 的区别 259 | 260 | #### LiveWS.ws 261 | 262 | LiveWS 内部 WebSocket 对象,需要时可以直接操作 263 | 264 | Doc: 265 | 266 | #### LiveTCP.socket 267 | 268 | LiveTCP 内部 TCP Socket 对象,需要时可以直接操作 269 | 270 | Doc: 271 | 272 | #### LiveTCP.buffer 273 | 274 | LiveTCP内部TCP流的Buffer缓冲区,有不完整的包 275 | 276 | #### LiveTCP.splitBuffer() 277 | 278 | 处理LiveTCP内部TCP流的Buffer缓冲区,把其中完整的包交给decoder处理 279 | 280 | ### BenchMark 简单对比 281 | 282 | 在连接了640个直播间后过了一分钟左右(@2.0.0) 283 | 284 | | | LiveWS (wss) | LiveWS (ws) | LiveTCP | 285 | | -------- | ------------ | ----------- | ------- | 286 | | 内存占用 | 63 MB | 26 MB | 18 MB | 287 | 288 |
289 | 290 | 参考资料: 291 | 292 | # 贡献 293 | 294 | 欢迎Issue和Pull Request! 295 | -------------------------------------------------------------------------------- /browser.ts: -------------------------------------------------------------------------------- 1 | export * from './src/browser' 2 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './src' 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bilibili-live-ws", 3 | "version": "6.3.1", 4 | "description": "Bilibili Live WebSocket/TCP API", 5 | "type": "commonjs", 6 | "main": "index.js", 7 | "browser": "browser.js", 8 | "scripts": { 9 | "test": "npm run clear; npm run unit", 10 | "unit": "nyc mocha --reporter=landing -r ts-node/register test/test.ts", 11 | "clear": "rm index.js index.d.ts browser.js browser.d.ts src/*.js src/*.d.ts;exit 0", 12 | "tsc": "tsc -b", 13 | "build": "npm run clear && npm run tsc" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/simon300000/bilibili-live-ws.git" 18 | }, 19 | "keywords": [ 20 | "bilibili", 21 | "api", 22 | "websocket", 23 | "live", 24 | "ws", 25 | "tcp" 26 | ], 27 | "author": "simon3000 ", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/simon300000/bilibili-live-ws/issues" 31 | }, 32 | "homepage": "https://github.com/simon300000/bilibili-live-ws#readme", 33 | "dependencies": { 34 | "array-flat-polyfill": "^1.0.1", 35 | "isomorphic-ws": "^5.0.0", 36 | "ws": "^8.2.3" 37 | }, 38 | "devDependencies": { 39 | "@types/chai": "^4.2.8", 40 | "@types/mocha": "^10.0.1", 41 | "@types/node": "^18.14.0", 42 | "@types/pako": "^2.0.0", 43 | "@types/ws": "^8.2.0", 44 | "chai": "^4.2.0", 45 | "mocha": "^10.2.0", 46 | "nyc": "^15.0.0", 47 | "ts-node": "^10.3.0", 48 | "typescript": "^4.4.4" 49 | }, 50 | "peerDependencies": { 51 | "buffer": "^6.0.3", 52 | "events": "^3.3.0", 53 | "pako": "^2.0.4" 54 | } 55 | } -------------------------------------------------------------------------------- /src/browser.ts: -------------------------------------------------------------------------------- 1 | import { inflates } from './inflate/browser' 2 | import { LiveWSBase, WSOptions } from './ws' 3 | import { KeepLive } from './common' 4 | 5 | export { WSOptions } 6 | export { LiveOptions, relayEvent } from './common' 7 | 8 | export class LiveWS extends LiveWSBase { 9 | constructor(roomid: number, opts?: WSOptions) { 10 | super(inflates as any, roomid, opts) 11 | } 12 | } 13 | 14 | export class KeepLiveWS extends KeepLive { 15 | constructor(roomid: number, opts?: WSOptions) { 16 | super(LiveWSBase, inflates as any, roomid, opts) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/buffer.ts: -------------------------------------------------------------------------------- 1 | import 'array-flat-polyfill' 2 | 3 | export type { Buffer } from 'buffer' 4 | export type Inflates = { inflateAsync: (b: Buffer) => Buffer | Promise, brotliDecompressAsync: (b: Buffer) => Buffer | Promise, Buffer: typeof Buffer } 5 | 6 | // https://github.com/lovelyyoshino/Bilibili-Live-API/blob/master/API.WebSocket.md 7 | 8 | const cutBuffer = (buffer: Buffer) => { 9 | const bufferPacks: Buffer[] = [] 10 | let size: number 11 | for (let i = 0; i < buffer.length; i += size) { 12 | size = buffer.readInt32BE(i) 13 | bufferPacks.push(buffer.slice(i, i + size)) 14 | } 15 | return bufferPacks 16 | } 17 | 18 | export const makeDecoder = ({ inflateAsync, brotliDecompressAsync }: Inflates) => { 19 | const decoder = async (buffer: Buffer) => { 20 | const packs = await Promise.all(cutBuffer(buffer) 21 | .map(async buf => { 22 | const body = buf.slice(16) 23 | const protocol = buf.readInt16BE(6) 24 | const operation = buf.readInt32BE(8) 25 | 26 | let type = 'unknow' 27 | if (operation === 3) { 28 | type = 'heartbeat' 29 | } else if (operation === 5) { 30 | type = 'message' 31 | } else if (operation === 8) { 32 | type = 'welcome' 33 | } 34 | 35 | let data: any 36 | if (protocol === 0) { 37 | data = JSON.parse(String(body)) 38 | } 39 | if (protocol === 1 && body.length === 4) { 40 | data = body.readUIntBE(0, 4) 41 | } 42 | if (protocol === 2) { 43 | data = await decoder(await inflateAsync(body)) 44 | } 45 | if (protocol === 3) { 46 | data = await decoder(await brotliDecompressAsync(body)) 47 | } 48 | 49 | return { buf, type, protocol, data } 50 | })) 51 | 52 | return packs.flatMap(pack => { 53 | if (pack.protocol === 2 || pack.protocol === 3) { 54 | return pack.data as typeof packs 55 | } 56 | return pack 57 | }) 58 | } 59 | 60 | return decoder 61 | } 62 | 63 | type EncodeType = 'heartbeat' | 'join' 64 | 65 | export const encoder = (type: EncodeType, { Buffer }: Inflates, body: any = '') => { 66 | const blank = Buffer.alloc(16) 67 | if (typeof body !== 'string') { 68 | body = JSON.stringify(body) 69 | } 70 | const head = Buffer.from(blank) 71 | const buffer = Buffer.from(body) 72 | 73 | head.writeInt32BE(buffer.length + head.length, 0) 74 | head.writeInt16BE(16, 4) 75 | head.writeInt16BE(1, 6) 76 | if (type === 'heartbeat') { 77 | head.writeInt32BE(2, 8) 78 | } 79 | if (type === 'join') { 80 | head.writeInt32BE(7, 8) 81 | } 82 | head.writeInt32BE(1, 12) 83 | return Buffer.concat([head, buffer]) 84 | } 85 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | 3 | import { encoder, makeDecoder, Inflates } from './buffer' 4 | 5 | export type LiveOptions = { protover?: 1 | 2 | 3, key?: string, authBody?: any, uid?: number, buvid?: string } 6 | 7 | export const relayEvent = Symbol('relay') 8 | 9 | class NiceEventEmitter extends EventEmitter { 10 | emit(eventName: string | symbol, ...params: any[]) { 11 | super.emit(eventName, ...params) 12 | super.emit(relayEvent, eventName, ...params) 13 | return true 14 | } 15 | } 16 | 17 | export class Live extends NiceEventEmitter { 18 | roomid: number 19 | online: number 20 | live: boolean 21 | closed: boolean 22 | timeout: ReturnType 23 | 24 | inflates: Inflates 25 | 26 | send: (data: Buffer) => void 27 | close: () => void 28 | 29 | constructor(inflates: Inflates, roomid: number, { send, close, protover = 3, key, authBody, uid = 0, buvid }: { send: (data: Buffer) => void, close: () => void } & LiveOptions) { 30 | if (typeof roomid !== 'number' || Number.isNaN(roomid)) { 31 | throw new Error(`roomid ${roomid} must be Number not NaN`) 32 | } 33 | 34 | super() 35 | this.inflates = inflates 36 | this.roomid = roomid 37 | this.online = 0 38 | this.live = false 39 | this.closed = false 40 | this.timeout = setTimeout(() => { }, 0) 41 | 42 | this.send = send 43 | this.close = () => { 44 | this.closed = true 45 | close() 46 | } 47 | 48 | this.on('message', async buffer => { 49 | const packs = await makeDecoder(inflates)(buffer) 50 | packs.forEach(({ type, data }) => { 51 | if (type === 'welcome') { 52 | this.live = true 53 | this.emit('live') 54 | this.send(encoder('heartbeat', inflates)) 55 | } 56 | if (type === 'heartbeat') { 57 | this.online = data 58 | clearTimeout(this.timeout) 59 | this.timeout = setTimeout(() => this.heartbeat(), 1000 * 30) 60 | this.emit('heartbeat', this.online) 61 | } 62 | if (type === 'message') { 63 | this.emit('msg', data) 64 | const cmd = data.cmd || (data.msg && data.msg.cmd) 65 | if (cmd) { 66 | if (cmd.includes('DANMU_MSG')) { 67 | this.emit('DANMU_MSG', data) 68 | } else { 69 | this.emit(cmd, data) 70 | } 71 | } 72 | } 73 | }) 74 | }) 75 | 76 | this.on('open', () => { 77 | if (authBody) { 78 | if (typeof authBody === 'object') { 79 | authBody = encoder('join', inflates, authBody) 80 | } 81 | this.send(authBody) 82 | } else { 83 | const hi: { uid: number, roomid: number, protover: number, platform: string, type: number, key?: string, buvid?: string } = { uid: uid, roomid, protover, platform: 'web', type: 2 } 84 | if (key) { 85 | hi.key = key 86 | } 87 | if (buvid) { 88 | hi.buvid = buvid 89 | } 90 | const buf = encoder('join', inflates, hi) 91 | this.send(buf) 92 | } 93 | }) 94 | 95 | this.on('close', () => { 96 | clearTimeout(this.timeout) 97 | }) 98 | 99 | this.on('_error', error => { 100 | this.close() 101 | this.emit('error', error) 102 | }) 103 | } 104 | 105 | heartbeat() { 106 | this.send(encoder('heartbeat', this.inflates)) 107 | } 108 | 109 | getOnline() { 110 | this.heartbeat() 111 | return new Promise(resolve => this.once('heartbeat', resolve)) 112 | } 113 | } 114 | 115 | 116 | export class KeepLive extends EventEmitter { 117 | params: ConstructorParameters 118 | closed: boolean 119 | interval: number 120 | timeout: number 121 | connection: InstanceType 122 | Base: Base 123 | 124 | constructor(Base: Base, ...params: ConstructorParameters) { 125 | super() 126 | this.params = params 127 | this.closed = false 128 | this.interval = 100 129 | this.timeout = 45 * 1000 130 | this.connection = new (Base as any)(...this.params) 131 | this.Base = Base 132 | this.connect(false) 133 | } 134 | 135 | connect(reconnect = true) { 136 | if (reconnect) { 137 | this.connection.close() 138 | this.connection = new (this.Base as any)(...this.params) 139 | } 140 | const connection = this.connection 141 | 142 | let timeout = setTimeout(() => { 143 | connection.close() 144 | connection.emit('timeout') 145 | }, this.timeout) 146 | 147 | connection.on(relayEvent, (eventName: string, ...params: any[]) => { 148 | if (eventName !== 'error') { 149 | this.emit(eventName, ...params) 150 | } 151 | }) 152 | 153 | connection.on('error', (e: any) => this.emit('e', e)) 154 | connection.on('close', () => { 155 | if (!this.closed) { 156 | setTimeout(() => this.connect(), this.interval) 157 | } 158 | }) 159 | 160 | connection.on('heartbeat', () => { 161 | clearTimeout(timeout) 162 | timeout = setTimeout(() => { 163 | connection.close() 164 | connection.emit('timeout') 165 | }, this.timeout) 166 | }) 167 | 168 | connection.on('close', () => { 169 | clearTimeout(timeout) 170 | }) 171 | } 172 | 173 | get online() { 174 | return this.connection.online 175 | } 176 | 177 | get roomid() { 178 | return this.connection.roomid 179 | } 180 | 181 | close() { 182 | this.closed = true 183 | this.connection.close() 184 | } 185 | 186 | heartbeat() { 187 | return this.connection.heartbeat() 188 | } 189 | 190 | getOnline() { 191 | return this.connection.getOnline() 192 | } 193 | 194 | send(data: Buffer) { 195 | return this.connection.send(data) 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/extra.ts: -------------------------------------------------------------------------------- 1 | type GET_DANMU_INFO = { 2 | code: number 3 | message: string 4 | ttl: number 5 | data: { 6 | business_id: number 7 | group: string 8 | host_list: { 9 | host: string 10 | port: number 11 | wss_port: number 12 | ws_port: number 13 | }[] 14 | max_delay: number 15 | refresh_rate: number 16 | refresh_row_factor: number 17 | token: string 18 | } 19 | } 20 | 21 | export const getConf = async (roomid: number) => { 22 | // eslint-disable-next-line @typescript-eslint/no-var-requires 23 | const raw = await fetch(`https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=${roomid}`).then(w => w.json()) as GET_DANMU_INFO 24 | const { data: { token: key, host_list: [{ host }] } } = raw 25 | const address = `wss://${host}/sub` 26 | return { key, host, address, raw } 27 | } 28 | 29 | export const getRoomid = async (short: number) => { 30 | // eslint-disable-next-line @typescript-eslint/no-var-requires 31 | const { data: { room_id } } = await fetch(`https://api.live.bilibili.com/room/v1/Room/mobileRoomInit?id=${short}`).then(w => w.json()) 32 | return room_id 33 | } 34 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { inflates } from './inflate/node' 2 | import { LiveWSBase, WSOptions } from './ws' 3 | import { LiveTCPBase, TCPOptions } from './tcp' 4 | import { KeepLive } from './common' 5 | 6 | export { getConf, getRoomid } from './extra' 7 | export { TCPOptions, WSOptions } 8 | export { LiveOptions, relayEvent } from './common' 9 | 10 | export class LiveWS extends LiveWSBase { 11 | constructor(roomid: number, opts?: WSOptions) { 12 | super(inflates, roomid, opts) 13 | } 14 | } 15 | 16 | export class LiveTCP extends LiveTCPBase { 17 | constructor(roomid: number, opts?: TCPOptions) { 18 | super(inflates, roomid, opts) 19 | } 20 | } 21 | 22 | export class KeepLiveWS extends KeepLive { 23 | constructor(roomid: number, opts?: WSOptions) { 24 | super(LiveWSBase, inflates, roomid, opts) 25 | } 26 | } 27 | 28 | 29 | export class KeepLiveTCP extends KeepLive { 30 | constructor(roomid: number, opts?: TCPOptions) { 31 | super(LiveTCPBase, inflates, roomid, opts) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/inflate/browser.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'buffer/' 2 | 3 | import { BrotliDecode } from './brotli' 4 | import { inflate } from 'pako' 5 | 6 | const inflateAsync = (d: Buffer) => Buffer.from(inflate(d)) 7 | const brotliDecompressAsync = (d: Buffer) => Buffer.from(BrotliDecode(Int8Array.from(d))) 8 | 9 | export const inflates = { inflateAsync, brotliDecompressAsync, Buffer } 10 | -------------------------------------------------------------------------------- /src/inflate/node.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'buffer' 2 | import { inflate, brotliDecompress } from 'zlib' 3 | import { promisify } from 'util' 4 | 5 | const inflateAsync = promisify[0], Parameters[2]>[1]>(inflate) 6 | const brotliDecompressAsync = promisify[0], Parameters[1]>[1]>(brotliDecompress) 7 | 8 | export const inflates = { inflateAsync, brotliDecompressAsync, Buffer } 9 | -------------------------------------------------------------------------------- /src/tcp.ts: -------------------------------------------------------------------------------- 1 | import net, { Socket } from 'net' 2 | 3 | import { Inflates } from './buffer' 4 | import { LiveOptions, Live } from './common' 5 | 6 | export type TCPOptions = LiveOptions & { host?: string, port?: number } 7 | 8 | export class LiveTCPBase extends Live { 9 | socket: Socket 10 | buffer: Buffer 11 | i: number 12 | 13 | constructor(inflates: Inflates, roomid: number, { host = 'broadcastlv.chat.bilibili.com', port = 2243, ...options}: TCPOptions = {}) { 14 | const socket = net.connect(port, host) 15 | const send = (data: Buffer) => { 16 | socket.write(data) 17 | } 18 | const close = () => this.socket.end() 19 | 20 | super(inflates, roomid, { send, close, ...options }) 21 | 22 | this.i = 0 23 | this.buffer = Buffer.alloc(0) 24 | 25 | socket.on('ready', () => this.emit('open')) 26 | socket.on('close', () => this.emit('close')) 27 | socket.on('error', (...params) => this.emit('_error', ...params)) 28 | socket.on('data', buffer => { 29 | this.buffer = Buffer.concat([this.buffer, buffer]) 30 | this.splitBuffer() 31 | }) 32 | this.socket = socket 33 | } 34 | 35 | splitBuffer() { 36 | while (this.buffer.length >= 4 && this.buffer.readInt32BE(0) <= this.buffer.length) { 37 | const size = this.buffer.readInt32BE(0) 38 | const pack = this.buffer.slice(0, size) 39 | this.buffer = this.buffer.slice(size) 40 | this.i++ 41 | if (this.i > 5) { 42 | this.i = 0 43 | this.buffer = Buffer.from(this.buffer) 44 | } 45 | this.emit('message', pack) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ws.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import { Agent } from 'http' 3 | import IsomorphicWebSocket from 'isomorphic-ws' 4 | 5 | import { Inflates } from './buffer' 6 | import { LiveOptions, Live } from './common' 7 | 8 | export type WSOptions = LiveOptions & { address?: string, agent?: Agent } 9 | 10 | export const isNode = !!IsomorphicWebSocket.Server 11 | 12 | class WebSocket extends EventEmitter { 13 | ws: IsomorphicWebSocket 14 | 15 | constructor(address: string, inflates: Inflates, ...args: any[]) { 16 | super() 17 | 18 | const ws = new IsomorphicWebSocket(address, ...(isNode ? args : [])) 19 | this.ws = ws 20 | 21 | ws.onopen = () => this.emit('open') 22 | ws.onmessage = isNode ? ({ data }) => this.emit('message', data) : async ({ data }) => this.emit('message', inflates.Buffer.from(await new Response(data as unknown as InstanceType).arrayBuffer())) 23 | ws.onerror = () => this.emit('error') 24 | ws.onclose = () => this.emit('close') 25 | } 26 | 27 | get readyState() { 28 | return this.ws.readyState 29 | } 30 | 31 | send(data: Buffer) { 32 | this.ws.send(data) 33 | } 34 | 35 | close(code?: number, data?: string) { 36 | this.ws.close(code, data) 37 | } 38 | } 39 | 40 | export class LiveWSBase extends Live { 41 | ws: InstanceType 42 | 43 | constructor(inflates: Inflates, roomid: number, { address = 'wss://broadcastlv.chat.bilibili.com/sub', agent, ...options }: WSOptions = {}) { 44 | const ws = new WebSocket(address, inflates, { agent }) 45 | const send = (data: Buffer) => { 46 | if (ws.readyState === 1) { 47 | ws.send(data) 48 | } 49 | } 50 | const close = () => this.ws.close() 51 | 52 | super(inflates, roomid, { send, close, ...options }) 53 | 54 | ws.on('open', (...params) => this.emit('open', ...params)) 55 | ws.on('message', data => this.emit('message', data as Buffer)) 56 | ws.on('close', (code, reason) => this.emit('close', code, reason)) 57 | ws.on('error', error => this.emit('_error', error)) 58 | 59 | this.ws = ws 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import { once } from 'events' 2 | 3 | import { assert } from 'chai' 4 | 5 | import { LiveWS, LiveTCP, KeepLiveWS, KeepLiveTCP, getConf, getRoomid } from '..' 6 | import { LiveWS as LiveWSBrowser, KeepLiveWS as KeepLiveWSBrowser } from '../browser' 7 | 8 | const TIMEOUT = 1000 * 25 9 | const watch = (live: LiveWS | LiveTCP | KeepLiveWS | KeepLiveTCP) => setTimeout(() => { 10 | if (!live.closed) { 11 | live.close() 12 | } 13 | }, TIMEOUT) 14 | 15 | describe('extra', function() { 16 | it('getRoomid', async () => { 17 | const roomid = await getRoomid(255) 18 | assert.strictEqual(roomid, 48743) 19 | }) 20 | }) 21 | 22 | Object.entries({ LiveWS, LiveTCP, KeepLiveWS, KeepLiveTCP, LiveWSBrowser, KeepLiveWSBrowser }) 23 | .forEach(([name, Live]) => { 24 | describe(name, function() { 25 | this.retries(4) 26 | this.timeout(1000 * 25) 27 | context('Connect', function() { 28 | it('online', async function() { 29 | const live = new Live(12235923) 30 | watch(live) 31 | const [online] = await once(live, 'heartbeat') 32 | live.close() 33 | return assert.isAbove(online, 0) 34 | }) 35 | it('roomid must be number', function() { 36 | // @ts-ignore 37 | return assert.throw(() => new Live('12235923')) 38 | }) 39 | it('roomid can not be NaN', function() { 40 | return assert.throw(() => new Live(NaN)) 41 | }) 42 | }) 43 | context('properties', function() { 44 | context('roomid', function() { 45 | Object.entries({ Mea: 12235923, nana: 21304638, fubuki: 11588230 }) 46 | .forEach(([name, roomid]) => { 47 | it(`roomid ${name}`, async function() { 48 | const live = new Live(roomid) 49 | watch(live) 50 | await once(live, 'live') 51 | live.close() 52 | return assert.strictEqual(live.roomid, roomid) 53 | }) 54 | }) 55 | }) 56 | it('online', async function() { 57 | const live = new Live(12235923) 58 | watch(live) 59 | const [online] = await once(live, 'heartbeat') 60 | live.close() 61 | return assert.strictEqual(online, live.online) 62 | }) 63 | it('closed', async function() { 64 | const live = new Live(12235923) 65 | watch(live) 66 | assert.isFalse(live.closed) 67 | await once(live, 'live') 68 | live.close() 69 | assert.isTrue(live.closed) 70 | }) 71 | }) 72 | context('functions', function() { 73 | it('close', async function() { 74 | const live = new Live(12235923) 75 | watch(live) 76 | await once(live, 'heartbeat') 77 | const close = await new Promise(resolve => { 78 | live.on('close', () => resolve('closed')) 79 | live.close() 80 | }) 81 | return assert.strictEqual(close, 'closed') 82 | }) 83 | it('getOnline', async function() { 84 | const live = new Live(12235923) 85 | watch(live) 86 | await once(live, 'live') 87 | const online = await live.getOnline() 88 | live.close() 89 | return assert.isAbove(online, 0) 90 | }) 91 | if (name.includes('Keep')) { 92 | it('no error relay', async function() { 93 | const live = new Live(12235923) as KeepLiveWS | KeepLiveTCP 94 | watch(live) 95 | await once(live, 'live') 96 | await new Promise((resolve, reject) => { 97 | live.once('error', reject) 98 | live.connection.emit('error', new Error('This shold not be caught')) 99 | setTimeout(resolve, 1000) 100 | }) 101 | live.close() 102 | }) 103 | } 104 | if (name.includes('Keep')) { 105 | it('close and reopen', async function() { 106 | const live = new Live(12235923) as KeepLiveWS | KeepLiveTCP 107 | watch(live) 108 | await once(live, 'live') 109 | live.connection.close() 110 | await once(live, 'live') 111 | live.close() 112 | }) 113 | } else { 114 | it('close on error', async function() { 115 | const live = new Live(12235923) 116 | watch(live) 117 | await once(live, 'heartbeat') 118 | const close = await new Promise(resolve => { 119 | live.on('close', () => resolve('closed')) 120 | live.on('error', () => { }) 121 | live.emit('_error', Error()) 122 | }) 123 | return assert.strictEqual(close, 'closed') 124 | }) 125 | } 126 | }) 127 | context('options', function() { 128 | it('protover: 1', async function() { 129 | const live = new Live(12235923, { protover: 1 }) 130 | watch(live) 131 | const [online] = await once(live, 'heartbeat') 132 | live.close() 133 | return assert.isAbove(online, 0) 134 | }) 135 | it('protover: 3', async function() { 136 | const live = new Live(12235923, { protover: 3 }) 137 | watch(live) 138 | const [online] = await once(live, 'heartbeat') 139 | live.close() 140 | return assert.isAbove(online, 0) 141 | }) 142 | if (name.includes('WS')) { 143 | it('address', async function() { 144 | const L = Live as typeof LiveWS || KeepLiveWS 145 | const live = new L(12235923, { address: 'wss://broadcastlv.chat.bilibili.com/sub' }) 146 | watch(live) 147 | const [online] = await once(live, 'heartbeat') 148 | live.close() 149 | return assert.isAbove(online, 0) 150 | }) 151 | } else if (name.includes('TCP')) { 152 | it('host, port', async function() { 153 | const live = new Live(12235923, { host: 'broadcastlv.chat.bilibili.com', port: 2243 }) 154 | watch(live) 155 | const [online] = await once(live, 'heartbeat') 156 | live.close() 157 | return assert.isAbove(online, 0) 158 | }) 159 | } else { 160 | throw new Error('no options test') 161 | } 162 | it('key: token', async function() { 163 | const { key, host, address } = await getConf(12235923) 164 | const live = new Live(12235923, { key, host, address } as any) 165 | watch(live) 166 | const [online] = await once(live, 'heartbeat') 167 | live.close() 168 | return assert.isAbove(online, 0) 169 | }) 170 | }) 171 | }) 172 | }) 173 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "lib": ["es2020", "dom"], 5 | "module": "commonjs", 6 | "noImplicitAny": true, 7 | "declaration": true, 8 | "moduleResolution": "node", 9 | "esModuleInterop": true, 10 | "strict": true 11 | }, 12 | "include": [ 13 | "src" 14 | ], 15 | "files": [ 16 | "index.ts", 17 | "browser.ts" 18 | ] 19 | } 20 | --------------------------------------------------------------------------------