@laplace.live/ws 7.1.9 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ //#region src/events.ts
2
+ /**
3
+ * A typed {@link Event} that carries an arbitrary data payload.
4
+ *
5
+ * Used throughout the library to deliver structured messages such as
6
+ * heartbeat counts, danmaku payloads, and other server-pushed data.
7
+ *
8
+ * Also used internally with `LaplaceRawEvent<Event>` as a catch-all meta-event
9
+ * (type `"event"`) to forward all events through {@link KeepLive}.
10
+ *
11
+ * @typeParam T - The type of the data payload attached to this event.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * live.addEventListener('heartbeat', (e) => {
16
+ * console.log('online:', e.data)
17
+ * })
18
+ * ```
19
+ */
20
+ var LaplaceRawEvent = class extends Event {
21
+ data;
22
+ constructor(type, data) {
23
+ super(type);
24
+ this.data = data;
25
+ }
26
+ };
27
+ /**
28
+ * Typed {@link EventTarget} base class for live connections.
29
+ *
30
+ * Provides typed `addEventListener` / `removeEventListener` via
31
+ * {@link LiveEventMap} and a `dispatchEvent` override that emits a
32
+ * catch-all `"event"` meta-event for every dispatched event.
33
+ *
34
+ * Extend this instead of `EventTarget` when building wrappers around
35
+ * `LiveWS` / `KeepLiveWS` to inherit typed event signatures
36
+ * automatically — no `declare` duplication required.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * import { LaplaceEventTarget, LaplaceRawEvent } from '@laplace.live/ws'
41
+ *
42
+ * class MyWrapper extends LaplaceEventTarget {
43
+ * // addEventListener is already typed — no extra `declare` needed
44
+ * }
45
+ * ```
46
+ */
47
+ var LaplaceEventTarget = class extends EventTarget {
48
+ dispatchEvent(event) {
49
+ const result = super.dispatchEvent(event);
50
+ super.dispatchEvent(new LaplaceRawEvent("event", event));
51
+ return result;
52
+ }
53
+ };
54
+ //#endregion
55
+ //#region src/keep-live.ts
56
+ /**
57
+ * Auto-reconnecting wrapper around a {@link Live} subclass.
58
+ *
59
+ * `KeepLive` maintains a persistent connection to a Bilibili live room by
60
+ * automatically re-creating the underlying {@link Live} instance whenever
61
+ * the connection drops or a heartbeat timeout is reached. All events from
62
+ * the inner connection are forwarded to this instance.
63
+ *
64
+ * @typeParam T - The concrete {@link Live} instance type being managed.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * const keep = new KeepLiveWS(12345, { key: '...' })
69
+ * keep.addEventListener('heartbeat', (e) => console.log('online:', e.data))
70
+ * keep.addEventListener('DANMU_MSG', ({ data }) => console.log('danmaku:', data.msg_id))
71
+ * ```
72
+ */
73
+ var KeepLive = class extends LaplaceEventTarget {
74
+ /** @internal Factory that creates a fresh connection with the original arguments. */
75
+ createConnection;
76
+ /** `true` after {@link close} has been called; prevents further reconnects. */
77
+ closed;
78
+ /** Delay in milliseconds before attempting a reconnect. */
79
+ interval;
80
+ /** Maximum milliseconds to wait for a heartbeat before forcing a reconnect. */
81
+ timeout;
82
+ /** The current underlying connection instance. */
83
+ connection;
84
+ constructor(createConnection) {
85
+ super();
86
+ this.createConnection = createConnection;
87
+ this.closed = false;
88
+ this.interval = 3e3;
89
+ this.timeout = 45 * 1e3;
90
+ this.connection = this.createConnection();
91
+ this.connect(false);
92
+ }
93
+ /**
94
+ * Wire up event forwarding and timeout handling on the current connection.
95
+ * When `reconnect` is `true`, the previous connection is closed and a fresh
96
+ * one is created via the factory.
97
+ *
98
+ * @param reconnect - Whether to tear down and recreate the connection first.
99
+ */
100
+ connect(reconnect = true) {
101
+ if (reconnect) {
102
+ const old = this.connection;
103
+ this.connection = this.createConnection();
104
+ old.close();
105
+ }
106
+ const connection = this.connection;
107
+ let timeout = setTimeout(() => {
108
+ connection.close();
109
+ connection.dispatchEvent(new Event("timeout"));
110
+ }, this.timeout);
111
+ connection.addEventListener("event", (e) => {
112
+ if (this.connection !== connection) return;
113
+ const evt = e.data;
114
+ if (evt.type !== "error") if (evt instanceof LaplaceRawEvent) this.dispatchEvent(new LaplaceRawEvent(evt.type, evt.data));
115
+ else this.dispatchEvent(new Event(evt.type));
116
+ });
117
+ connection.addEventListener("error", () => this.dispatchEvent(new Event("e")));
118
+ connection.addEventListener("close", () => {
119
+ if (!this.closed && this.connection === connection) setTimeout(() => this.connect(), this.interval);
120
+ });
121
+ connection.addEventListener("heartbeat", () => {
122
+ if (this.connection !== connection) return;
123
+ clearTimeout(timeout);
124
+ timeout = setTimeout(() => {
125
+ connection.close();
126
+ connection.dispatchEvent(new Event("timeout"));
127
+ }, this.timeout);
128
+ });
129
+ connection.addEventListener("close", () => {
130
+ clearTimeout(timeout);
131
+ });
132
+ }
133
+ /** Latest known online viewer count from the underlying connection. */
134
+ get online() {
135
+ return this.connection.online;
136
+ }
137
+ /** The live room ID. */
138
+ get roomid() {
139
+ return this.connection.roomid;
140
+ }
141
+ /** Permanently close the connection and stop reconnecting. */
142
+ close() {
143
+ this.closed = true;
144
+ this.connection.close();
145
+ }
146
+ /** Send a heartbeat packet to the server. */
147
+ heartbeat() {
148
+ return this.connection.heartbeat();
149
+ }
150
+ /**
151
+ * Send a heartbeat and resolve with the online viewer count from the
152
+ * next heartbeat response.
153
+ * @returns A promise that resolves with the current online count.
154
+ */
155
+ getOnline() {
156
+ return this.connection.getOnline();
157
+ }
158
+ /**
159
+ * Send raw binary data through the underlying connection.
160
+ * @param data - The binary packet to send.
161
+ */
162
+ send(data) {
163
+ return this.connection.send(data);
164
+ }
165
+ };
166
+ //#endregion
167
+ //#region src/buffer.ts
168
+ const textEncoder = new TextEncoder();
169
+ const textDecoder = new TextDecoder();
170
+ function concatUint8Arrays(arrs) {
171
+ let totalLength = 0;
172
+ for (const arr of arrs) totalLength += arr.length;
173
+ const result = new Uint8Array(totalLength);
174
+ let offset = 0;
175
+ for (const arr of arrs) {
176
+ result.set(arr, offset);
177
+ offset += arr.length;
178
+ }
179
+ return result;
180
+ }
181
+ const cutBuffer = (buffer) => {
182
+ const bufferPacks = [];
183
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
184
+ let size;
185
+ for (let i = 0; i < buffer.length; i += size) {
186
+ size = view.getInt32(i);
187
+ bufferPacks.push(buffer.slice(i, i + size));
188
+ }
189
+ return bufferPacks;
190
+ };
191
+ /**
192
+ * Create an async decoder function that parses raw Bilibili live WebSocket
193
+ * frames into structured packets.
194
+ *
195
+ * Each frame has a 16-byte header:
196
+ * - bytes 0–3: total packet length (int32 BE)
197
+ * - bytes 4–5: header length (int16 BE, always 16)
198
+ * - bytes 6–7: protocol version (0 = JSON, 1 = heartbeat, 2 = zlib, 3 = brotli)
199
+ * - bytes 8–11: operation (2 = heartbeat req, 3 = heartbeat resp, 5 = message, 7 = join, 8 = welcome)
200
+ * - bytes 12–15: sequence id
201
+ *
202
+ * Compressed frames (protocol 2/3) are recursively decoded after inflation.
203
+ *
204
+ * @link https://github.com/lovelyyoshino/Bilibili-Live-API/blob/master/API.WebSocket.md
205
+ * @param inflates - Platform-specific decompression implementations.
206
+ * @returns An async function that decodes a raw `Uint8Array` frame into an
207
+ * array of `{ buf, type, protocol, data }` packets.
208
+ */
209
+ const makeDecoder = ({ inflateAsync, brotliDecompressAsync }) => {
210
+ const decoder = async (buffer) => {
211
+ return (await Promise.all(cutBuffer(buffer).map(async (buf) => {
212
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
213
+ const body = buf.slice(16);
214
+ const protocol = view.getInt16(6);
215
+ const operation = view.getInt32(8);
216
+ let type = "unknow";
217
+ if (operation === 3) type = "heartbeat";
218
+ else if (operation === 5) type = "message";
219
+ else if (operation === 8) type = "welcome";
220
+ let data;
221
+ if (protocol === 0) data = JSON.parse(textDecoder.decode(body));
222
+ if (protocol === 1 && body.length === 4) data = new DataView(body.buffer, body.byteOffset, body.byteLength).getUint32(0);
223
+ if (protocol === 2) data = await decoder(await inflateAsync(body));
224
+ if (protocol === 3) data = await decoder(await brotliDecompressAsync(body));
225
+ return {
226
+ buf,
227
+ type,
228
+ protocol,
229
+ data
230
+ };
231
+ }))).flatMap((pack) => {
232
+ if (pack.protocol === 2 || pack.protocol === 3) return pack.data;
233
+ return pack;
234
+ });
235
+ };
236
+ return decoder;
237
+ };
238
+ /**
239
+ * Encode a Bilibili live protocol packet with a 16-byte header and an
240
+ * optional body.
241
+ *
242
+ * Header layout:
243
+ * - bytes 0–3: total length (header + body)
244
+ * - bytes 4–5: header length (16)
245
+ * - bytes 6–7: protocol version (1)
246
+ * - bytes 8–11: operation (`2` for heartbeat, `7` for join)
247
+ * - bytes 12–15: sequence id (1)
248
+ *
249
+ * @param type - Packet type: `"heartbeat"` (keep-alive) or `"join"` (room enter).
250
+ * @param body - Optional payload. Objects are JSON-stringified; strings are
251
+ * encoded as UTF-8. Defaults to an empty string.
252
+ * @returns The encoded packet as a `Uint8Array`.
253
+ */
254
+ const encoder = (type, body = "") => {
255
+ const encoded = typeof body === "string" ? body : JSON.stringify(body);
256
+ const head = new Uint8Array(16);
257
+ const headView = new DataView(head.buffer, head.byteOffset, head.byteLength);
258
+ const buffer = textEncoder.encode(encoded);
259
+ headView.setInt32(0, buffer.length + head.length);
260
+ headView.setInt16(4, 16);
261
+ headView.setInt16(6, 1);
262
+ if (type === "heartbeat") headView.setInt32(8, 2);
263
+ if (type === "join") headView.setInt32(8, 7);
264
+ headView.setInt32(12, 1);
265
+ return concatUint8Arrays([head, buffer]);
266
+ };
267
+ //#endregion
268
+ //#region src/live.ts
269
+ /**
270
+ * Base class for a single Bilibili live room connection.
271
+ *
272
+ * Extends {@link LaplaceEventTarget} and emits the following events:
273
+ *
274
+ * | Event | Payload | Description |
275
+ * |---------------|--------------------|--------------------------------------------------|
276
+ * | `open` | — | Underlying transport connected. |
277
+ * | `live` | — | Server acknowledged the join (room entered). |
278
+ * | `heartbeat` | `LaplaceRawEvent<number>`| Online viewer count received from server. |
279
+ * | `msg` | `LaplaceRawEvent<any>` | Any server command message. |
280
+ * | `DANMU_MSG` | `LaplaceRawEvent<any>` | Danmaku (chat) message. |
281
+ * | `close` | — | Connection closed. |
282
+ * | `error` | — | Unrecoverable error (connection is closed). |
283
+ * | `event` | `LaplaceRawEvent<Event>` | Meta-event wrapping every dispatched event. |
284
+ *
285
+ * Subclasses ({@link LiveWSBase}, {@link LiveTCPBase}) provide the concrete
286
+ * transport and supply `send` / `close` callbacks to this constructor.
287
+ *
288
+ * @param inflates - Platform-specific inflate + brotli decompressors.
289
+ * @param roomid - Numeric Bilibili live room ID.
290
+ * @param options - Transport callbacks and authentication options.
291
+ *
292
+ * @throws {Error} If `roomid` is not a finite number.
293
+ */
294
+ var Live = class extends LaplaceEventTarget {
295
+ /** The live room ID this instance is connected to. */
296
+ roomid;
297
+ /** Latest known online viewer count, updated on each heartbeat. */
298
+ online;
299
+ /** `true` after the server acknowledges the join (`welcome` packet). */
300
+ live;
301
+ /** `true` after {@link close} has been called. */
302
+ closed;
303
+ /** @internal Handle for the heartbeat interval timer. */
304
+ timeout;
305
+ /** Send raw binary data over the underlying transport. */
306
+ send;
307
+ /** Gracefully close the connection and set {@link closed} to `true`. */
308
+ close;
309
+ constructor(inflates, roomid, { send, close, protover = 3, key, authBody, uid = 0, buvid }) {
310
+ if (typeof roomid !== "number" || Number.isNaN(roomid)) throw new Error(`roomid ${roomid} must be Number not NaN`);
311
+ super();
312
+ this.roomid = roomid;
313
+ this.online = 0;
314
+ this.live = false;
315
+ this.closed = false;
316
+ this.timeout = setTimeout(() => {}, 0);
317
+ this.send = send;
318
+ this.close = () => {
319
+ if (this.closed) return;
320
+ this.closed = true;
321
+ close();
322
+ this.dispatchEvent(new Event("close"));
323
+ };
324
+ const decode = makeDecoder(inflates);
325
+ this.addEventListener("message", async (e) => {
326
+ const buffer = e.data;
327
+ (await decode(buffer)).forEach(({ type, data }) => {
328
+ if (type === "welcome") {
329
+ this.live = true;
330
+ this.dispatchEvent(new Event("live"));
331
+ this.send(encoder("heartbeat"));
332
+ }
333
+ if (type === "heartbeat") {
334
+ this.online = data;
335
+ clearTimeout(this.timeout);
336
+ this.timeout = setTimeout(() => this.heartbeat(), 1e3 * 30);
337
+ this.dispatchEvent(new LaplaceRawEvent("heartbeat", this.online));
338
+ }
339
+ if (type === "message") {
340
+ this.dispatchEvent(new LaplaceRawEvent("msg", data));
341
+ const cmd = data.cmd || data.msg?.cmd;
342
+ if (cmd) this.dispatchEvent(new LaplaceRawEvent(cmd, data));
343
+ }
344
+ });
345
+ });
346
+ this.addEventListener("open", () => {
347
+ if (authBody) this.send(authBody instanceof Uint8Array ? authBody : encoder("join", authBody));
348
+ else {
349
+ const hi = {
350
+ uid,
351
+ roomid,
352
+ protover,
353
+ platform: "web",
354
+ type: 2
355
+ };
356
+ if (key) hi.key = key;
357
+ if (buvid) hi.buvid = buvid;
358
+ const buf = encoder("join", hi);
359
+ this.send(buf);
360
+ }
361
+ });
362
+ this.addEventListener("close", () => {
363
+ clearTimeout(this.timeout);
364
+ });
365
+ this.addEventListener("_error", () => {
366
+ this.close();
367
+ this.dispatchEvent(new Event("error"));
368
+ });
369
+ }
370
+ /** Send a heartbeat packet to the server. */
371
+ heartbeat() {
372
+ this.send(encoder("heartbeat"));
373
+ }
374
+ /**
375
+ * Send a heartbeat and resolve with the online viewer count from the
376
+ * next heartbeat response.
377
+ * @returns A promise that resolves with the current online count.
378
+ */
379
+ getOnline() {
380
+ this.heartbeat();
381
+ return new Promise((resolve) => this.addEventListener("heartbeat", (e) => resolve(e.data), { once: true }));
382
+ }
383
+ };
384
+ //#endregion
385
+ //#region src/ws.ts
386
+ /**
387
+ * WebSocket transport for Bilibili live room connections.
388
+ *
389
+ * Wraps a native {@link WebSocket}, wiring its lifecycle events (`open`,
390
+ * `message`, `close`, `error`) into the {@link Live} event system. Binary
391
+ * frames are forwarded as `LaplaceRawEvent<Uint8Array>` for protocol decoding.
392
+ *
393
+ * Not typically instantiated directly — use {@link LiveWS} (server) or the
394
+ * browser-specific `LiveWS` which inject the appropriate inflate
395
+ * implementation.
396
+ *
397
+ * @param inflates - Platform-specific inflate/brotli decompressors.
398
+ * @param roomid - Numeric Bilibili live room ID.
399
+ * @param options - WebSocket address and authentication options.
400
+ */
401
+ var LiveWSBase = class extends Live {
402
+ /** The underlying native WebSocket instance. */
403
+ ws;
404
+ constructor(inflates, roomid, { address = "wss://broadcastlv.chat.bilibili.com/sub", createWebSocket, ...options } = {}) {
405
+ const ws = createWebSocket ? createWebSocket(address) : new WebSocket(address);
406
+ const send = (data) => {
407
+ if (ws.readyState === 1) ws.send(data);
408
+ };
409
+ const close = () => this.ws.close();
410
+ super(inflates, roomid, {
411
+ send,
412
+ close,
413
+ ...options
414
+ });
415
+ ws.binaryType = "arraybuffer";
416
+ ws.addEventListener("open", (e) => this.dispatchEvent(new Event(e.type)));
417
+ ws.addEventListener("message", (e) => this.dispatchEvent(new LaplaceRawEvent("message", new Uint8Array(e.data))));
418
+ ws.addEventListener("close", (e) => {
419
+ if (!this.closed) this.dispatchEvent(new Event(e.type));
420
+ });
421
+ ws.addEventListener("error", () => this.dispatchEvent(new Event("_error")));
422
+ this.ws = ws;
423
+ }
424
+ };
425
+ //#endregion
426
+ export { LaplaceRawEvent as a, LaplaceEventTarget as i, Live as n, KeepLive as r, LiveWSBase as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laplace.live/ws",
3
- "version": "7.1.9",
3
+ "version": "8.0.0",
4
4
  "description": "LAPLACE Live! flavored bilibili live WebSocket/TCP API",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -32,12 +32,13 @@
32
32
  "dist"
33
33
  ],
34
34
  "scripts": {
35
- "build": "tsup",
35
+ "build": "tsdown",
36
36
  "check": "tsc --noEmit",
37
37
  "test": "bun test --timeout 5000",
38
38
  "prepublishOnly": "bun run build",
39
39
  "release": "changeset publish",
40
- "version": "changeset version"
40
+ "version": "changeset version",
41
+ "lint": "biome check"
41
42
  },
42
43
  "repository": {
43
44
  "type": "git",
@@ -62,14 +63,14 @@
62
63
  "provenance": true
63
64
  },
64
65
  "dependencies": {
65
- "@laplace.live/internal": "^1.3.7"
66
+ "@laplace.live/internal": "^1.3.18"
66
67
  },
67
68
  "devDependencies": {
68
- "@biomejs/biome": "^2.4.12",
69
- "@changesets/cli": "^2.30.0",
70
- "@types/bun": "^1.3.12",
71
- "playwright": "^1.59.1",
72
- "tsup": "^8.5.1",
73
- "typescript": "^5.9.3"
69
+ "@biomejs/biome": "^2.4.16",
70
+ "@changesets/cli": "^2.31.0",
71
+ "@types/bun": "^1.3.14",
72
+ "playwright": "^1.60.0",
73
+ "tsdown": "^0.22.1",
74
+ "typescript": "^6.0.3"
74
75
  }
75
76
  }