cortico 0.1.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.
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/package.json +43 -0
- package/src/boot.ts +46 -0
- package/src/bot.ts +1342 -0
- package/src/config-file.ts +41 -0
- package/src/core/README.md +92 -0
- package/src/core/billing.ts +26 -0
- package/src/core/blobs.ts +106 -0
- package/src/core/bus.ts +311 -0
- package/src/core/config-schema.ts +201 -0
- package/src/core/config.ts +262 -0
- package/src/core/core.ts +699 -0
- package/src/core/cost.ts +244 -0
- package/src/core/event-store.ts +304 -0
- package/src/core/fork.ts +113 -0
- package/src/core/generation.ts +118 -0
- package/src/core/instance-lock.ts +215 -0
- package/src/core/ipc-logger.ts +105 -0
- package/src/core/language.ts +62 -0
- package/src/core/log-context.ts +38 -0
- package/src/core/loop.ts +1858 -0
- package/src/core/markers.ts +48 -0
- package/src/core/prefix.ts +121 -0
- package/src/core/run.ts +95 -0
- package/src/core/secrets.ts +18 -0
- package/src/core/session.ts +56 -0
- package/src/core/sessions.ts +261 -0
- package/src/core/state.ts +104 -0
- package/src/core/template.ts +72 -0
- package/src/core/timers.ts +129 -0
- package/src/core/tool-log.ts +135 -0
- package/src/core/transcript.ts +92 -0
- package/src/core/truncate.ts +121 -0
- package/src/core/types.ts +1140 -0
- package/src/core/usage-log.ts +79 -0
- package/src/core/util.ts +386 -0
- package/src/deploy-listing.ts +160 -0
- package/src/deploy.ts +162 -0
- package/src/extensions/README.md +72 -0
- package/src/extensions/dry-mount.ts +390 -0
- package/src/extensions/manifest.ts +153 -0
- package/src/extensions/runtime.ts +42 -0
- package/src/extensions.ts +519 -0
- package/src/launcher.ts +285 -0
- package/src/paths.ts +155 -0
- package/src/protocol/open-responses/LICENSE +201 -0
- package/src/protocol/open-responses/README.md +14 -0
- package/src/protocol/open-responses/context-helpers.ts +27 -0
- package/src/protocol/open-responses/context-log.ts +59 -0
- package/src/protocol/open-responses/context.ts +89 -0
- package/src/protocol/open-responses/generated.ts +115 -0
- package/src/protocol/open-responses/index.ts +33 -0
- package/src/protocol/open-responses/openapi.json +4230 -0
- package/src/protocol/open-responses/stream.ts +146 -0
- package/src/protocol/open-responses/tokens.ts +31 -0
- package/src/providers/README.md +86 -0
- package/src/providers/base.ts +111 -0
- package/src/providers/configuration.ts +97 -0
- package/src/providers/console/config.ts +21 -0
- package/src/providers/console/settings.ts +443 -0
- package/src/providers/console/strings.ts +74 -0
- package/src/providers/console/types.ts +11 -0
- package/src/providers/llamacpp/archive.ts +148 -0
- package/src/providers/llamacpp/catalog.ts +247 -0
- package/src/providers/llamacpp/config.ts +36 -0
- package/src/providers/llamacpp/console/client.ts +6 -0
- package/src/providers/llamacpp/console/models-panel.ts +120 -0
- package/src/providers/llamacpp/console/runtime-panel.ts +173 -0
- package/src/providers/llamacpp/console/server.ts +153 -0
- package/src/providers/llamacpp/index.ts +95 -0
- package/src/providers/llamacpp/native.ts +51 -0
- package/src/providers/llamacpp/options.ts +117 -0
- package/src/providers/llamacpp/runtime-store.ts +139 -0
- package/src/providers/llamacpp/runtime.ts +214 -0
- package/src/providers/llamacpp/server.ts +286 -0
- package/src/providers/llamacpp/strings.ts +230 -0
- package/src/providers/openai-responses-compat/config.ts +20 -0
- package/src/providers/openai-responses-compat/index.ts +89 -0
- package/src/providers/openai-responses-compat/native.ts +127 -0
- package/src/providers/openai-responses-compat/strings.ts +19 -0
- package/src/providers/pricebook.ts +75 -0
- package/src/providers/registry.ts +137 -0
- package/src/providers/strings.ts +71 -0
- package/src/providers/transport/chat.ts +156 -0
- package/src/providers/transport/errors.ts +48 -0
- package/src/providers/transport/history.ts +68 -0
- package/src/providers/transport/native-input.ts +83 -0
- package/src/providers/transport/native-types.ts +13 -0
- package/src/providers/transport/response-assembly.ts +191 -0
- package/src/providers/transport/response-http.ts +196 -0
- package/src/providers/transport/response-meters.ts +27 -0
- package/src/providers/transport/responses-input.ts +56 -0
- package/src/web/README.md +84 -0
- package/src/web/client/console-pages/builtins/llm-settings/panel.ts +625 -0
- package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +168 -0
- package/src/web/client/console-pages/builtins/llm-settings/strings.ts +213 -0
- package/src/web/client/console-pages/builtins.ts +10 -0
- package/src/web/client/console-pages/context.ts +135 -0
- package/src/web/client/console-pages/host.ts +633 -0
- package/src/web/client/console-pages/loader.ts +132 -0
- package/src/web/client/console-pages/strings.ts +90 -0
- package/src/web/client/console-pages/tools/strings.ts +49 -0
- package/src/web/client/console-pages/tools/view.ts +215 -0
- package/src/web/client/core/api.ts +200 -0
- package/src/web/client/core/language.ts +56 -0
- package/src/web/client/core/lifecycle.ts +132 -0
- package/src/web/client/core/router.ts +199 -0
- package/src/web/client/core/stream.ts +157 -0
- package/src/web/client/core/websocket.ts +82 -0
- package/src/web/client/features/appearance/index.ts +333 -0
- package/src/web/client/features/appearance/strings.ts +87 -0
- package/src/web/client/features/config/index.ts +11 -0
- package/src/web/client/features/config/strings.ts +41 -0
- package/src/web/client/features/config/view.ts +428 -0
- package/src/web/client/features/core/events.ts +197 -0
- package/src/web/client/features/core/index.ts +254 -0
- package/src/web/client/features/core/run.ts +56 -0
- package/src/web/client/features/core/runlog.ts +214 -0
- package/src/web/client/features/core/sessions.ts +53 -0
- package/src/web/client/features/core/strings.ts +157 -0
- package/src/web/client/features/extensions/index.ts +422 -0
- package/src/web/client/features/extensions/strings.ts +164 -0
- package/src/web/client/features/feature.ts +93 -0
- package/src/web/client/features/live/context.ts +280 -0
- package/src/web/client/features/live/fork.ts +156 -0
- package/src/web/client/features/live/index.ts +392 -0
- package/src/web/client/features/live/onboarding.ts +113 -0
- package/src/web/client/features/live/protocol.ts +122 -0
- package/src/web/client/features/live/sessions.ts +71 -0
- package/src/web/client/features/live/status.ts +68 -0
- package/src/web/client/features/live/strings.ts +252 -0
- package/src/web/client/features/live/timeline.ts +590 -0
- package/src/web/client/features/prompts/editor.ts +292 -0
- package/src/web/client/features/prompts/index.ts +346 -0
- package/src/web/client/features/prompts/strings.ts +136 -0
- package/src/web/client/features/prompts/view.ts +256 -0
- package/src/web/client/features/providers/index.ts +100 -0
- package/src/web/client/features/providers/strings.ts +23 -0
- package/src/web/client/features/settings/general.ts +28 -0
- package/src/web/client/features/settings/index.ts +82 -0
- package/src/web/client/features/settings/strings.ts +33 -0
- package/src/web/client/features/storage/index.ts +1 -0
- package/src/web/client/features/storage/strings.ts +45 -0
- package/src/web/client/features/storage/view.ts +154 -0
- package/src/web/client/features/usage/chart.ts +486 -0
- package/src/web/client/features/usage/index.ts +407 -0
- package/src/web/client/features/usage/labels.ts +46 -0
- package/src/web/client/features/usage/range.ts +36 -0
- package/src/web/client/features/usage/state.ts +37 -0
- package/src/web/client/features/usage/strings.ts +191 -0
- package/src/web/client/features/usage/tooltip.ts +86 -0
- package/src/web/client/features/usage/types.ts +96 -0
- package/src/web/client/features/worlds/index.ts +424 -0
- package/src/web/client/features/worlds/strings.ts +121 -0
- package/src/web/client/main.ts +273 -0
- package/src/web/client/shell/avatar.ts +233 -0
- package/src/web/client/shell/index.ts +549 -0
- package/src/web/client/shell/strings.ts +89 -0
- package/src/web/client/theme/handoff.ts +43 -0
- package/src/web/client/theme/palette.ts +109 -0
- package/src/web/client/theme/registry.ts +130 -0
- package/src/web/client/theme/storage.ts +72 -0
- package/src/web/client/theme/strings.ts +123 -0
- package/src/web/client/theme/studio.ts +366 -0
- package/src/web/client/ui/actions.ts +109 -0
- package/src/web/client/ui/data.ts +185 -0
- package/src/web/client/ui/dom.ts +23 -0
- package/src/web/client/ui/fields.ts +191 -0
- package/src/web/client/ui/format.ts +75 -0
- package/src/web/client/ui/icons.ts +183 -0
- package/src/web/client/ui/images.ts +91 -0
- package/src/web/client/ui/index.ts +89 -0
- package/src/web/client/ui/lamp.ts +125 -0
- package/src/web/client/ui/log.ts +98 -0
- package/src/web/client/ui/overlay.ts +184 -0
- package/src/web/client/ui/page.ts +8 -0
- package/src/web/client/ui/prompt-input.tsx +311 -0
- package/src/web/client/ui/sheet.ts +131 -0
- package/src/web/client/ui/strings.ts +71 -0
- package/src/web/console-pages.ts +367 -0
- package/src/web/files.ts +107 -0
- package/src/web/path-picker.ts +296 -0
- package/src/web/public/index.html +17 -0
- package/src/web/public/styles.css +1715 -0
- package/src/web/server.ts +1883 -0
- package/src/web/shared/client-panel.ts +603 -0
- package/src/web/shared/console-protocol.ts +560 -0
- package/src/web/shared/css.d.ts +1 -0
- package/src/web/shared/path-picker.ts +23 -0
- package/src/web/shared/theme.ts +256 -0
- package/src/web/theme-store.ts +37 -0
- package/src/world.ts +449 -0
- package/src/worlds/bilibili/ENV_PROMPT.md +5 -0
- package/src/worlds/bilibili/README.md +176 -0
- package/src/worlds/bilibili/audience-admission.ts +995 -0
- package/src/worlds/bilibili/client.ts +528 -0
- package/src/worlds/bilibili/coalescing-buffer.ts +125 -0
- package/src/worlds/bilibili/config.ts +185 -0
- package/src/worlds/bilibili/console/client.ts +202 -0
- package/src/worlds/bilibili/definition.ts +35 -0
- package/src/worlds/bilibili/gift-frame.ts +131 -0
- package/src/worlds/bilibili/normalize.ts +452 -0
- package/src/worlds/bilibili/overlay/announcement.ts +76 -0
- package/src/worlds/bilibili/overlay/assets.ts +82 -0
- package/src/worlds/bilibili/overlay/model.ts +494 -0
- package/src/worlds/bilibili/overlay/project.ts +202 -0
- package/src/worlds/bilibili/overlay/server.ts +286 -0
- package/src/worlds/bilibili/overlay/types.ts +217 -0
- package/src/worlds/bilibili/overlay/web/app.js +690 -0
- package/src/worlds/bilibili/overlay/web/editor.css +297 -0
- package/src/worlds/bilibili/overlay/web/editor.html +102 -0
- package/src/worlds/bilibili/overlay/web/editor.js +2116 -0
- package/src/worlds/bilibili/overlay/web/overlay.html +13 -0
- package/src/worlds/bilibili/overlay/web/styles.css +258 -0
- package/src/worlds/bilibili/protobuf.ts +123 -0
- package/src/worlds/bilibili/wire.ts +94 -0
- package/src/worlds/bilibili/world.ts +1389 -0
- package/src/worlds/console-fixture/ENV_PROMPT.md +1 -0
- package/src/worlds/console-fixture/console/client.ts +72 -0
- package/src/worlds/console-fixture/world.ts +73 -0
- package/src/worlds/index.ts +15 -0
- package/src/worlds/minecraft/ENV_PROMPT.md +32 -0
- package/src/worlds/minecraft/ENV_PROMPT_CAMERA.md +2 -0
- package/src/worlds/minecraft/LICENSE-mineflayer-pathfinder.txt +21 -0
- package/src/worlds/minecraft/README.md +917 -0
- package/src/worlds/minecraft/blueprint-plan.ts +915 -0
- package/src/worlds/minecraft/blueprint-registry.ts +566 -0
- package/src/worlds/minecraft/blueprint-repair.ts +236 -0
- package/src/worlds/minecraft/blueprint-resource.ts +272 -0
- package/src/worlds/minecraft/blueprint.ts +492 -0
- package/src/worlds/minecraft/body-lease.ts +340 -0
- package/src/worlds/minecraft/bridge.ts +749 -0
- package/src/worlds/minecraft/check.ts +651 -0
- package/src/worlds/minecraft/chests.ts +292 -0
- package/src/worlds/minecraft/client-launch.ts +272 -0
- package/src/worlds/minecraft/client-options.ts +85 -0
- package/src/worlds/minecraft/client-skins.ts +164 -0
- package/src/worlds/minecraft/client-window.ps1 +91 -0
- package/src/worlds/minecraft/client.ts +423 -0
- package/src/worlds/minecraft/combat-context.ts +109 -0
- package/src/worlds/minecraft/combat.ts +1552 -0
- package/src/worlds/minecraft/config.ts +499 -0
- package/src/worlds/minecraft/console/access.ts +218 -0
- package/src/worlds/minecraft/console/client.ts +244 -0
- package/src/worlds/minecraft/console/log.ts +110 -0
- package/src/worlds/minecraft/console/mount.ts +420 -0
- package/src/worlds/minecraft/console/skin.ts +257 -0
- package/src/worlds/minecraft/console/style.css +154 -0
- package/src/worlds/minecraft/console/world.ts +449 -0
- package/src/worlds/minecraft/deaths.ts +135 -0
- package/src/worlds/minecraft/definition.ts +18 -0
- package/src/worlds/minecraft/engine-child.ts +274 -0
- package/src/worlds/minecraft/engine-ipc.ts +98 -0
- package/src/worlds/minecraft/entity-facts.ts +132 -0
- package/src/worlds/minecraft/escape.ts +407 -0
- package/src/worlds/minecraft/executor.ts +14494 -0
- package/src/worlds/minecraft/explored.ts +156 -0
- package/src/worlds/minecraft/geometry.ts +205 -0
- package/src/worlds/minecraft/goal-plan.ts +647 -0
- package/src/worlds/minecraft/item-break.ts +126 -0
- package/src/worlds/minecraft/item-facts.ts +146 -0
- package/src/worlds/minecraft/item-pick.ts +67 -0
- package/src/worlds/minecraft/level-dat.ts +176 -0
- package/src/worlds/minecraft/log.ts +132 -0
- package/src/worlds/minecraft/mineflayer-fixes.ts +1180 -0
- package/src/worlds/minecraft/names.ts +377 -0
- package/src/worlds/minecraft/pathfinder-lib.d.ts +13 -0
- package/src/worlds/minecraft/pathfinder-perf.ts +796 -0
- package/src/worlds/minecraft/piglin.ts +49 -0
- package/src/worlds/minecraft/policy.ts +547 -0
- package/src/worlds/minecraft/precheck.ts +655 -0
- package/src/worlds/minecraft/proxy.ts +445 -0
- package/src/worlds/minecraft/ranged.ts +635 -0
- package/src/worlds/minecraft/readouts.ts +102 -0
- package/src/worlds/minecraft/round.ts +26 -0
- package/src/worlds/minecraft/search-observation.ts +92 -0
- package/src/worlds/minecraft/server-config.ts +467 -0
- package/src/worlds/minecraft/server.ts +658 -0
- package/src/worlds/minecraft/show.ts +84 -0
- package/src/worlds/minecraft/skills.ts +1763 -0
- package/src/worlds/minecraft/terrain.ts +1735 -0
- package/src/worlds/minecraft/window.ts +96 -0
- package/src/worlds/minecraft/works.ts +179 -0
- package/src/worlds/minecraft/world.ts +6438 -0
- package/src/worlds/qq/ENV_PROMPT.md +5 -0
- package/src/worlds/qq/config.ts +108 -0
- package/src/worlds/qq/console/client.ts +126 -0
- package/src/worlds/qq/console/events.ts +155 -0
- package/src/worlds/qq/console/gate.ts +232 -0
- package/src/worlds/qq/console/roster.ts +203 -0
- package/src/worlds/qq/console/style.css +65 -0
- package/src/worlds/qq/conversation.ts +28 -0
- package/src/worlds/qq/definition.ts +62 -0
- package/src/worlds/qq/driver.ts +375 -0
- package/src/worlds/qq/history-tools.ts +209 -0
- package/src/worlds/qq/normalize.ts +323 -0
- package/src/worlds/qq/qface-map.ts +282 -0
- package/src/worlds/qq/vision-prompt.ts +27 -0
- package/src/worlds/qq/vision.ts +642 -0
- package/src/worlds/qq/vlm.ts +142 -0
- package/src/worlds/qq/world.ts +1503 -0
- package/src/worlds/terminal/ENV_PROMPT.md +11 -0
- package/src/worlds/terminal/config.ts +9 -0
- package/src/worlds/terminal/definition.ts +14 -0
- package/src/worlds/terminal/world.ts +700 -0
- package/src/worlds/websearch/ENV_PROMPT.md +1 -0
- package/src/worlds/websearch/brave-client.ts +261 -0
- package/src/worlds/websearch/config.ts +75 -0
- package/src/worlds/websearch/definition.ts +20 -0
- package/src/worlds/websearch/world.ts +171 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 直播间长连客户端(web 弹幕协议,只读)。
|
|
3
|
+
*
|
|
4
|
+
* 连接前的 HTTP 请求:
|
|
5
|
+
* 1. `finger/spi` 取 buvid3
|
|
6
|
+
* 2. `x/web-interface/nav` 取 WBI 密钥(未登录也返回,只是 code=-101)与自己的 uid
|
|
7
|
+
* 3. `Room/get_info` 把短号换成真实房间号(认证包只认真实号)
|
|
8
|
+
* 4. `getDanmuInfo`(WBI 签名)拿弹幕服务器列表与 token
|
|
9
|
+
* 然后 wss 连上去发认证包,每 30 秒一个心跳。
|
|
10
|
+
*
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import WebSocket from 'ws';
|
|
14
|
+
import type { Logger, ShutdownExternalCheck } from '../../core/types.ts';
|
|
15
|
+
import { encodePacket, OP, parseFrame } from './wire.ts';
|
|
16
|
+
|
|
17
|
+
const UA =
|
|
18
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
|
|
19
|
+
|
|
20
|
+
const HEARTBEAT_MS = 30_000;
|
|
21
|
+
const BACKOFF_MIN_MS = 2_000;
|
|
22
|
+
const BACKOFF_MAX_MS = 60_000;
|
|
23
|
+
/**
|
|
24
|
+
* WebSocket 的 TCP/TLS/Upgrade 握手时限,防止未产生 open/error/close 的挂起阻断重连。超时由 ws 发出 error、close,再经 close → fail() 恢复。
|
|
25
|
+
*/
|
|
26
|
+
export const HANDSHAKE_TIMEOUT_MS = 15_000;
|
|
27
|
+
/** 握手四步 HTTP 的总时限;超时同样走 fail() 排下一次重连 */
|
|
28
|
+
export const BOOTSTRAP_TIMEOUT_MS = 15_000;
|
|
29
|
+
/**
|
|
30
|
+
* 重连看门狗阈值:平台在播而 phase 停在 connecting 超过这么久,由 live_status
|
|
31
|
+
* 轮询强制 fail() 重来。retrying 且退避计时器还挂着的不算卡死(退避上限 60 秒,
|
|
32
|
+
* 它自己会到点重连)。
|
|
33
|
+
*/
|
|
34
|
+
export const RECONNECT_WATCHDOG_MS = 60_000;
|
|
35
|
+
/**
|
|
36
|
+
* 运行期 live_status 只读轮询,补充 WS 断连窗口或漏推的状态读数。此层仅更新状态,转沿成文与告警由 onStatus → onLiveStatus 处理。
|
|
37
|
+
*/
|
|
38
|
+
export const ROOM_POLL_MS = 100_000;
|
|
39
|
+
/** HTTP 412 后的状态轮询退避时间。 */
|
|
40
|
+
export const ROOM_POLL_RISK_HOLD_MS = 600_000;
|
|
41
|
+
/** 关机验证属于 World 停机预算的一小段,不新增全局关机步骤。 */
|
|
42
|
+
export const SHUTDOWN_VERIFY_TIMEOUT_MS = 2_500;
|
|
43
|
+
|
|
44
|
+
/** WBI 签名的 64 位重排表(B 站前端里的常量) */
|
|
45
|
+
const MIXIN_TAB = [
|
|
46
|
+
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29,
|
|
47
|
+
28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25,
|
|
48
|
+
54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
export type LivePhase = 'stopped' | 'connecting' | 'connected' | 'retrying';
|
|
52
|
+
|
|
53
|
+
export interface LiveStatus {
|
|
54
|
+
phase: LivePhase;
|
|
55
|
+
/** 配置里填的房间号(可能是短号) */
|
|
56
|
+
roomId: number;
|
|
57
|
+
/** 换算出的真实房间号 */
|
|
58
|
+
realRoomId: number | null;
|
|
59
|
+
title: string;
|
|
60
|
+
/** 主播是否正在直播 */
|
|
61
|
+
living: boolean;
|
|
62
|
+
/** 平台报告的本场开播时刻 */
|
|
63
|
+
liveStartedAt: number | null;
|
|
64
|
+
/** 登录态的 uid;0 = 匿名 */
|
|
65
|
+
selfUid: number;
|
|
66
|
+
lastError: string | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface LiveClientOptions {
|
|
70
|
+
roomId: number;
|
|
71
|
+
/** 登录 cookie;空串=匿名连接(收得到弹幕,但认不出人) */
|
|
72
|
+
sessdata: string;
|
|
73
|
+
onCmd: (msg: Record<string, unknown>) => void;
|
|
74
|
+
/** 状态变化(控制台徽标据此刷新) */
|
|
75
|
+
onStatus?: (status: LiveStatus) => void;
|
|
76
|
+
log: Logger;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class LiveClient {
|
|
80
|
+
private readonly opts: LiveClientOptions;
|
|
81
|
+
private ws: WebSocket | null = null;
|
|
82
|
+
private heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
83
|
+
private retry: ReturnType<typeof setTimeout> | null = null;
|
|
84
|
+
/** 运行期 live_status 轮询(见 ROOM_POLL_MS);start 布防、stop 撤防 */
|
|
85
|
+
private roomPoll: ReturnType<typeof setTimeout> | null = null;
|
|
86
|
+
private backoff = BACKOFF_MIN_MS;
|
|
87
|
+
private stopped = true;
|
|
88
|
+
private status: LiveStatus;
|
|
89
|
+
/** 当前 phase 的进入时刻;看门狗据此算 connecting 卡了多久 */
|
|
90
|
+
private phaseSince = Date.now();
|
|
91
|
+
private shutdownCheck: ShutdownExternalCheck;
|
|
92
|
+
|
|
93
|
+
constructor(opts: LiveClientOptions) {
|
|
94
|
+
this.opts = opts;
|
|
95
|
+
this.status = {
|
|
96
|
+
phase: 'stopped',
|
|
97
|
+
roomId: opts.roomId,
|
|
98
|
+
realRoomId: null,
|
|
99
|
+
title: '',
|
|
100
|
+
living: false,
|
|
101
|
+
liveStartedAt: null,
|
|
102
|
+
selfUid: 0,
|
|
103
|
+
lastError: null,
|
|
104
|
+
};
|
|
105
|
+
this.shutdownCheck = this.unknownShutdownCheck('尚未执行直播间状态验证');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
current(): LiveStatus {
|
|
109
|
+
return { ...this.status };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async start(): Promise<void> {
|
|
113
|
+
this.stopped = false;
|
|
114
|
+
this.shutdownCheck = this.unknownShutdownCheck('尚未执行直播间状态验证');
|
|
115
|
+
await this.connect();
|
|
116
|
+
// 轮询独立于 WS 连接的死活:WS 断着的窗口恰恰是最需要它的时候
|
|
117
|
+
this.scheduleRoomPoll(ROOM_POLL_MS);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async stop(): Promise<void> {
|
|
121
|
+
this.stopped = true;
|
|
122
|
+
if (this.retry) clearTimeout(this.retry);
|
|
123
|
+
this.retry = null;
|
|
124
|
+
if (this.roomPoll) clearTimeout(this.roomPoll);
|
|
125
|
+
this.roomPoll = null;
|
|
126
|
+
this.clearHeartbeat();
|
|
127
|
+
const ws = this.ws;
|
|
128
|
+
this.ws = null;
|
|
129
|
+
if (ws) {
|
|
130
|
+
try {
|
|
131
|
+
ws.close(1000, 'module stop');
|
|
132
|
+
} catch {
|
|
133
|
+
/* 已断 */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
this.patch({ phase: 'stopped' });
|
|
137
|
+
this.shutdownCheck = await this.verifyRoomEnded();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
shutdownVerification(): readonly ShutdownExternalCheck[] {
|
|
141
|
+
return [{ ...this.shutdownCheck }];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── 连接 ─────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
private async connect(): Promise<void> {
|
|
147
|
+
if (this.stopped) return;
|
|
148
|
+
this.patch({ phase: 'connecting', lastError: null });
|
|
149
|
+
let plan: { url: string; auth: Record<string, unknown> };
|
|
150
|
+
// 与 verifyRoomEnded 同一形制:到点既 abort 又 reject,不理会 abort 的上游也拖不住
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
let deadline: ReturnType<typeof setTimeout> | null = null;
|
|
153
|
+
try {
|
|
154
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
155
|
+
deadline = setTimeout(() => {
|
|
156
|
+
controller.abort();
|
|
157
|
+
reject(new Error(`握手接口超时(${BOOTSTRAP_TIMEOUT_MS}ms)`));
|
|
158
|
+
}, BOOTSTRAP_TIMEOUT_MS);
|
|
159
|
+
});
|
|
160
|
+
plan = await Promise.race([this.bootstrap(controller.signal), timeout]);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
this.fail(e instanceof Error ? e.message : String(e));
|
|
163
|
+
return;
|
|
164
|
+
} finally {
|
|
165
|
+
if (deadline) clearTimeout(deadline);
|
|
166
|
+
}
|
|
167
|
+
if (this.stopped) return;
|
|
168
|
+
|
|
169
|
+
const ws = new WebSocket(plan.url, {
|
|
170
|
+
headers: { 'User-Agent': UA, Origin: 'https://live.bilibili.com' },
|
|
171
|
+
handshakeTimeout: HANDSHAKE_TIMEOUT_MS,
|
|
172
|
+
});
|
|
173
|
+
this.ws = ws;
|
|
174
|
+
ws.on('open', () => {
|
|
175
|
+
ws.send(encodePacket(OP.AUTH, JSON.stringify(plan.auth)));
|
|
176
|
+
this.clearHeartbeat();
|
|
177
|
+
this.heartbeat = setInterval(() => {
|
|
178
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(encodePacket(OP.HEARTBEAT, ''));
|
|
179
|
+
}, HEARTBEAT_MS);
|
|
180
|
+
this.heartbeat.unref?.();
|
|
181
|
+
});
|
|
182
|
+
ws.on('message', (raw: WebSocket.RawData) => this.onFrame(toBuffer(raw)));
|
|
183
|
+
// 两个回调都只认当前 socket:fail()/stop() 已放弃的 socket 在 terminate/close
|
|
184
|
+
// 之后还会补一个 error + close,不能让它再触发一次 fail()。
|
|
185
|
+
ws.on('error', (err: Error) => {
|
|
186
|
+
if (this.ws === ws) this.opts.log.warn('直播长连出错', { err: err.message });
|
|
187
|
+
});
|
|
188
|
+
ws.on('close', (code: number, reason: Buffer) => {
|
|
189
|
+
if (this.ws !== ws) return;
|
|
190
|
+
this.ws = null;
|
|
191
|
+
this.clearHeartbeat();
|
|
192
|
+
if (this.stopped) return;
|
|
193
|
+
this.fail('长连断开', { code, reason: reason.toString() });
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private onFrame(buf: Buffer): void {
|
|
198
|
+
// 心跳回执里的人气值(kind:'popularity')无人消费,丢弃;面板那个读数走 POPULARITY_CHANGE 这条 JSON cmd。
|
|
199
|
+
for (const frame of parseFrame(buf)) {
|
|
200
|
+
if (frame.kind === 'auth') {
|
|
201
|
+
if (frame.code === 0) {
|
|
202
|
+
this.backoff = BACKOFF_MIN_MS;
|
|
203
|
+
this.patch({ phase: 'connected' });
|
|
204
|
+
this.opts.log.info(`已接入直播间 ${this.status.realRoomId}`, {
|
|
205
|
+
登录态: this.status.selfUid ? String(this.status.selfUid) : '匿名',
|
|
206
|
+
});
|
|
207
|
+
} else {
|
|
208
|
+
this.fail(`认证失败 code=${frame.code}`);
|
|
209
|
+
}
|
|
210
|
+
} else if (frame.kind === 'cmd') {
|
|
211
|
+
try {
|
|
212
|
+
this.opts.onCmd(frame.msg);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
this.opts.log.warn('直播消息处理失败', { err: String(e) });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 断线/失败统一入口:记一笔,退避后重连(重连要重新取 token,它会过期)。
|
|
222
|
+
* `data` 原样进日志(close 的 code/reason、看门狗的持续时长),断因事后可追。
|
|
223
|
+
*/
|
|
224
|
+
private fail(reason: string, data?: Record<string, unknown>): void {
|
|
225
|
+
this.clearHeartbeat();
|
|
226
|
+
const ws = this.ws;
|
|
227
|
+
this.ws = null;
|
|
228
|
+
if (ws) {
|
|
229
|
+
try {
|
|
230
|
+
ws.terminate();
|
|
231
|
+
} catch {
|
|
232
|
+
/* 已断 */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (this.stopped) return;
|
|
236
|
+
this.patch({ phase: 'retrying', lastError: reason });
|
|
237
|
+
this.opts.log.warn(`直播接入中断:${reason},${Math.round(this.backoff / 1000)} 秒后重连`, data);
|
|
238
|
+
if (this.retry) clearTimeout(this.retry);
|
|
239
|
+
this.retry = setTimeout(() => {
|
|
240
|
+
this.retry = null;
|
|
241
|
+
this.connect().catch((e) => this.opts.log.warn('重连失败', { err: String(e) }));
|
|
242
|
+
}, this.backoff);
|
|
243
|
+
this.retry.unref?.();
|
|
244
|
+
this.backoff = Math.min(this.backoff * 2, BACKOFF_MAX_MS);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private clearHeartbeat(): void {
|
|
248
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
249
|
+
this.heartbeat = null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private patch(part: Partial<LiveStatus>): void {
|
|
253
|
+
if (part.phase !== undefined && part.phase !== this.status.phase) this.phaseSince = Date.now();
|
|
254
|
+
this.status = { ...this.status, ...part };
|
|
255
|
+
this.opts.onStatus?.(this.current());
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── 运行期 live_status 轮询 ──────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
private scheduleRoomPoll(delayMs: number): void {
|
|
261
|
+
if (this.stopped) return;
|
|
262
|
+
if (this.roomPoll) clearTimeout(this.roomPoll);
|
|
263
|
+
this.roomPoll = setTimeout(() => {
|
|
264
|
+
void this.pollRoomInfo();
|
|
265
|
+
}, delayMs);
|
|
266
|
+
this.roomPoll.unref?.();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 只读一次 Room/get_info,把 live_status/live_time 的变化 patch 进状态
|
|
271
|
+
* (变化才 patch,免得每 100 秒空转一次 onStatus)。不调用任何写接口,
|
|
272
|
+
* 不在这里下任何"直播结束了"的结论——那是 World 侧转沿检测的事。
|
|
273
|
+
* 读完顺带跑一次重连看门狗:它是 WS 回调之外唯一按时醒来的地方。
|
|
274
|
+
*/
|
|
275
|
+
private async pollRoomInfo(): Promise<void> {
|
|
276
|
+
if (this.stopped) return;
|
|
277
|
+
let nextMs = ROOM_POLL_MS;
|
|
278
|
+
try {
|
|
279
|
+
const info = await this.getJson(
|
|
280
|
+
`https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${this.status.realRoomId ?? this.opts.roomId}`,
|
|
281
|
+
'',
|
|
282
|
+
);
|
|
283
|
+
if (numOf(info.code) !== 0) {
|
|
284
|
+
this.opts.log.warn('直播间状态轮询返回非零 code', { code: info.code, message: str(info.message) });
|
|
285
|
+
} else {
|
|
286
|
+
const room = obj(info.data);
|
|
287
|
+
const rawLiveStatus = room.live_status;
|
|
288
|
+
if (typeof rawLiveStatus === 'number' && Number.isFinite(rawLiveStatus)) {
|
|
289
|
+
const living = rawLiveStatus === 1;
|
|
290
|
+
const liveStartedAt = liveTimestamp(room.live_time);
|
|
291
|
+
if (living !== this.status.living || liveStartedAt !== this.status.liveStartedAt) {
|
|
292
|
+
this.opts.log.info('轮询读到 live_status 变化', { liveStatus: rawLiveStatus, living });
|
|
293
|
+
this.patch({ living, liveStartedAt });
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
this.opts.log.warn('直播间状态轮询响应缺少有效 live_status');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} catch (e) {
|
|
300
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
301
|
+
if (msg.includes('412')) {
|
|
302
|
+
nextMs = ROOM_POLL_RISK_HOLD_MS;
|
|
303
|
+
this.opts.log.warn(`直播间状态轮询失败(412),等待 ${Math.round(ROOM_POLL_RISK_HOLD_MS / 60_000)} 分钟后重试`);
|
|
304
|
+
} else {
|
|
305
|
+
this.opts.log.warn('直播间状态轮询失败', { err: msg });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
this.reconnectWatchdog();
|
|
309
|
+
this.scheduleRoomPoll(nextMs);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 重连看门狗(见 RECONNECT_WATCHDOG_MS)。握手停住时 ws 不会触发任何回调,
|
|
314
|
+
* 这里按 phase 的进入时刻兜底,强制走 fail() 重来。只在平台在播时管——
|
|
315
|
+
* 没开播的空窗没有弹幕可丢。
|
|
316
|
+
*/
|
|
317
|
+
private reconnectWatchdog(): void {
|
|
318
|
+
if (this.stopped || !this.status.living) return;
|
|
319
|
+
const phase = this.status.phase;
|
|
320
|
+
const stuck = phase === 'connecting' || (phase === 'retrying' && this.retry === null);
|
|
321
|
+
if (!stuck) return;
|
|
322
|
+
const elapsedMs = Date.now() - this.phaseSince;
|
|
323
|
+
if (elapsedMs < RECONNECT_WATCHDOG_MS) return;
|
|
324
|
+
this.fail(`重连看门狗:握手/重连 ${Math.round(elapsedMs / 1000)} 秒无结果`, { phase, elapsedMs });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── 握手四步 ─────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
private async bootstrap(signal: AbortSignal): Promise<{ url: string; auth: Record<string, unknown> }> {
|
|
330
|
+
const spi = await this.getJson('https://api.bilibili.com/x/frontend/finger/spi', '', signal);
|
|
331
|
+
const buvid3 = str(obj(spi.data).b_3);
|
|
332
|
+
const cookie = [`buvid3=${buvid3}`, this.opts.sessdata ? `SESSDATA=${this.opts.sessdata}` : '']
|
|
333
|
+
.filter(Boolean)
|
|
334
|
+
.join('; ');
|
|
335
|
+
|
|
336
|
+
const nav = await this.getJson('https://api.bilibili.com/x/web-interface/nav', cookie, signal);
|
|
337
|
+
const navData = obj(nav.data);
|
|
338
|
+
const wbi = obj(navData.wbi_img);
|
|
339
|
+
const mixin = mixinKey(keyOf(str(wbi.img_url)), keyOf(str(wbi.sub_url)));
|
|
340
|
+
const selfUid = numOf(navData.mid);
|
|
341
|
+
|
|
342
|
+
const info = await this.getJson(
|
|
343
|
+
`https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${this.opts.roomId}`,
|
|
344
|
+
cookie,
|
|
345
|
+
signal,
|
|
346
|
+
);
|
|
347
|
+
if (numOf(info.code) !== 0) throw new Error(`取直播间信息失败:${str(info.message)}`);
|
|
348
|
+
const room = obj(info.data);
|
|
349
|
+
const realRoomId = numOf(room.room_id);
|
|
350
|
+
this.patch({
|
|
351
|
+
realRoomId,
|
|
352
|
+
title: str(room.title),
|
|
353
|
+
living: numOf(room.live_status) === 1,
|
|
354
|
+
liveStartedAt: liveTimestamp(room.live_time),
|
|
355
|
+
selfUid,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const dm = await this.getJson(
|
|
359
|
+
`https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?${wbiQuery({ id: realRoomId, type: 0 }, mixin)}`,
|
|
360
|
+
cookie,
|
|
361
|
+
signal,
|
|
362
|
+
);
|
|
363
|
+
if (numOf(dm.code) !== 0) throw new Error(`取弹幕服务器失败:${numOf(dm.code)} ${str(dm.message)}`);
|
|
364
|
+
const dmData = obj(dm.data);
|
|
365
|
+
const hosts = Array.isArray(dmData.host_list) ? dmData.host_list : [];
|
|
366
|
+
const first = obj(hosts[0]);
|
|
367
|
+
const host = str(first.host);
|
|
368
|
+
if (!host) throw new Error('弹幕服务器列表为空');
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
url: `wss://${host}:${numOf(first.wss_port) || 443}/sub`,
|
|
372
|
+
auth: {
|
|
373
|
+
uid: selfUid,
|
|
374
|
+
roomid: realRoomId,
|
|
375
|
+
protover: 3,
|
|
376
|
+
platform: 'web',
|
|
377
|
+
type: 2,
|
|
378
|
+
buvid: buvid3,
|
|
379
|
+
key: str(dmData.token),
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private async getJson(
|
|
385
|
+
url: string,
|
|
386
|
+
cookie: string,
|
|
387
|
+
signal?: AbortSignal,
|
|
388
|
+
): Promise<Record<string, unknown>> {
|
|
389
|
+
const res = await fetch(url, {
|
|
390
|
+
method: 'GET',
|
|
391
|
+
signal,
|
|
392
|
+
headers: {
|
|
393
|
+
'User-Agent': UA,
|
|
394
|
+
Referer: 'https://live.bilibili.com/',
|
|
395
|
+
Origin: 'https://live.bilibili.com',
|
|
396
|
+
...(cookie ? { Cookie: cookie } : {}),
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
if (res.status === 412) throw new Error('HTTP 412');
|
|
400
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
401
|
+
const body: unknown = await res.json();
|
|
402
|
+
return obj(body);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** 只读 Room/get_info;不调用 StopStream、不关闭平台直播,也不管理推流进程。 */
|
|
406
|
+
private async verifyRoomEnded(): Promise<ShutdownExternalCheck> {
|
|
407
|
+
const roomId = this.status.realRoomId ?? this.opts.roomId;
|
|
408
|
+
const controller = new AbortController();
|
|
409
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
410
|
+
try {
|
|
411
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
412
|
+
timer = setTimeout(() => {
|
|
413
|
+
controller.abort();
|
|
414
|
+
reject(new Error(`只读探针超时(${SHUTDOWN_VERIFY_TIMEOUT_MS}ms)`));
|
|
415
|
+
}, SHUTDOWN_VERIFY_TIMEOUT_MS);
|
|
416
|
+
});
|
|
417
|
+
const info = await Promise.race([
|
|
418
|
+
this.getJson(
|
|
419
|
+
`https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${roomId}`,
|
|
420
|
+
'',
|
|
421
|
+
controller.signal,
|
|
422
|
+
),
|
|
423
|
+
timeout,
|
|
424
|
+
]);
|
|
425
|
+
if (typeof info.code !== 'number' || !Number.isFinite(info.code) || info.code !== 0) {
|
|
426
|
+
return this.unknownShutdownCheck(`Room/get_info 返回无效 code=${String(info.code)} ${str(info.message)}`);
|
|
427
|
+
}
|
|
428
|
+
const rawLiveStatus = obj(info.data).live_status;
|
|
429
|
+
if (typeof rawLiveStatus !== 'number' || !Number.isFinite(rawLiveStatus)) {
|
|
430
|
+
return this.unknownShutdownCheck('Room/get_info 响应缺少有效 live_status');
|
|
431
|
+
}
|
|
432
|
+
const liveStatus = rawLiveStatus;
|
|
433
|
+
if (liveStatus === 0) {
|
|
434
|
+
return {
|
|
435
|
+
key: 'bilibili.live-room',
|
|
436
|
+
label: `B 站直播间 ${roomId}`,
|
|
437
|
+
status: 'verified-ended',
|
|
438
|
+
detail: 'Room/get_info 已确认 live_status=0(未开播)',
|
|
439
|
+
manualAction: '无需操作。',
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
if (liveStatus === 1) {
|
|
443
|
+
return {
|
|
444
|
+
key: 'bilibili.live-room',
|
|
445
|
+
label: `B 站直播间 ${roomId}`,
|
|
446
|
+
status: 'still-live',
|
|
447
|
+
detail: 'Room/get_info 返回 live_status=1,平台仍显示直播中',
|
|
448
|
+
manualAction: '立即打开 B 站主播后台或直播伴侣手动下播,并检查 OBS/推流进程是否仍在推流。',
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
return this.unknownShutdownCheck(`Room/get_info 返回未知 live_status=${liveStatus}`);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
return this.unknownShutdownCheck(error instanceof Error ? error.message : String(error));
|
|
454
|
+
} finally {
|
|
455
|
+
if (timer) clearTimeout(timer);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private unknownShutdownCheck(detail: string): ShutdownExternalCheck {
|
|
460
|
+
const roomId = this.status.realRoomId ?? this.opts.roomId;
|
|
461
|
+
return {
|
|
462
|
+
key: 'bilibili.live-room',
|
|
463
|
+
label: `B 站直播间 ${roomId}`,
|
|
464
|
+
status: 'unknown',
|
|
465
|
+
detail,
|
|
466
|
+
manualAction: '立即打开 B 站主播后台或直播伴侣人工确认直播状态;在确认前不要假定直播已经结束。',
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── WBI 签名 ───────────────────────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
/** `https://.../7cd084941338484aae1ad9425b84077c.png` → `7cd0…077c` */
|
|
474
|
+
function keyOf(url: string): string {
|
|
475
|
+
return url.split('/').pop()?.split('.')[0] ?? '';
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function mixinKey(imgKey: string, subKey: string): string {
|
|
479
|
+
const raw = imgKey + subKey;
|
|
480
|
+
return MIXIN_TAB.map((i) => raw[i] ?? '').join('').slice(0, 32);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** 参数按键排序、剔掉 `!'()*`、补 wts,再 md5(query + mixinKey) 得 w_rid */
|
|
484
|
+
function wbiQuery(params: Record<string, string | number>, mixin: string, nowSec?: number): string {
|
|
485
|
+
const withTs: Record<string, string | number> = {
|
|
486
|
+
...params,
|
|
487
|
+
wts: nowSec ?? Math.floor(Date.now() / 1000),
|
|
488
|
+
};
|
|
489
|
+
const query = Object.keys(withTs)
|
|
490
|
+
.sort()
|
|
491
|
+
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(String(withTs[k]).replace(/[!'()*]/g, ''))}`)
|
|
492
|
+
.join('&');
|
|
493
|
+
return `${query}&w_rid=${createHash('md5').update(query + mixin).digest('hex')}`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── 小工具 ─────────────────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
function liveTimestamp(value: unknown): number | null {
|
|
499
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
500
|
+
return value < 1_000_000_000_000 ? Math.round(value * 1000) : Math.round(value);
|
|
501
|
+
}
|
|
502
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
503
|
+
const raw = value.trim();
|
|
504
|
+
if (/^\d+(?:\.\d+)?$/.test(raw)) return liveTimestamp(Number(raw));
|
|
505
|
+
const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)
|
|
506
|
+
? raw
|
|
507
|
+
: `${raw.replace(' ', 'T')}+08:00`;
|
|
508
|
+
const parsed = Date.parse(zoned);
|
|
509
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function toBuffer(raw: WebSocket.RawData): Buffer {
|
|
513
|
+
if (Buffer.isBuffer(raw)) return raw;
|
|
514
|
+
if (Array.isArray(raw)) return Buffer.concat(raw);
|
|
515
|
+
return Buffer.from(raw);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function obj(v: unknown): Record<string, unknown> {
|
|
519
|
+
return v !== null && typeof v === 'object' ? (v as Record<string, unknown>) : {};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function str(v: unknown): string {
|
|
523
|
+
return typeof v === 'string' ? v : '';
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function numOf(v: unknown): number {
|
|
527
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
528
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export interface CoalescingGroup<T> {
|
|
2
|
+
key: string;
|
|
3
|
+
items: readonly T[];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface CoalescingBufferState {
|
|
7
|
+
inputs: number;
|
|
8
|
+
outputs: number;
|
|
9
|
+
collapsed: number;
|
|
10
|
+
flushes: number;
|
|
11
|
+
capacityFlushes: number;
|
|
12
|
+
pendingItems: number;
|
|
13
|
+
pendingGroups: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PendingGroup<T> {
|
|
17
|
+
key: string;
|
|
18
|
+
items: T[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 固定窗按各组首件到达顺序输出。窗口不续期;run scope 只合并该事件流的当前连续段。
|
|
23
|
+
*/
|
|
24
|
+
export class CoalescingBuffer<T> {
|
|
25
|
+
private readonly emit: (groups: readonly CoalescingGroup<T>[]) => void;
|
|
26
|
+
private groups: PendingGroup<T>[] = [];
|
|
27
|
+
private sameKeyGroups = new Map<string, PendingGroup<T>>();
|
|
28
|
+
private runGroups = new Map<string, PendingGroup<T>>();
|
|
29
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
30
|
+
private pendingItems = 0;
|
|
31
|
+
private inputs = 0;
|
|
32
|
+
private outputs = 0;
|
|
33
|
+
private flushes = 0;
|
|
34
|
+
private capacityFlushes = 0;
|
|
35
|
+
|
|
36
|
+
constructor(emit: (groups: readonly CoalescingGroup<T>[]) => void) {
|
|
37
|
+
this.emit = emit;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
add(key: string, item: T, opts: {
|
|
41
|
+
windowMs: number;
|
|
42
|
+
maxItems: number;
|
|
43
|
+
/** 只与该事件流中紧邻的同 key 项归并;其他 scope 的事件不打断连续段。 */
|
|
44
|
+
runScope?: string;
|
|
45
|
+
}): void {
|
|
46
|
+
const first = this.pendingItems === 0;
|
|
47
|
+
let group: PendingGroup<T> | undefined;
|
|
48
|
+
if (opts.runScope) {
|
|
49
|
+
const active = this.runGroups.get(opts.runScope);
|
|
50
|
+
if (active?.key === key) group = active;
|
|
51
|
+
else {
|
|
52
|
+
group = { key, items: [] };
|
|
53
|
+
this.groups.push(group);
|
|
54
|
+
this.runGroups.set(opts.runScope, group);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
group = this.sameKeyGroups.get(key);
|
|
58
|
+
if (!group) {
|
|
59
|
+
group = { key, items: [] };
|
|
60
|
+
this.groups.push(group);
|
|
61
|
+
this.sameKeyGroups.set(key, group);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
group.items.push(item);
|
|
65
|
+
this.pendingItems += 1;
|
|
66
|
+
this.inputs += 1;
|
|
67
|
+
|
|
68
|
+
if (this.pendingItems >= opts.maxItems) {
|
|
69
|
+
this.flush('capacity');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!first) return;
|
|
73
|
+
if (opts.windowMs === 0) {
|
|
74
|
+
this.flush('window');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
this.timer = setTimeout(() => this.flush('window'), opts.windowMs);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
flush(reason: 'window' | 'capacity' | 'barrier' | 'stop' = 'barrier'): void {
|
|
81
|
+
if (this.pendingItems === 0) return;
|
|
82
|
+
if (this.timer) clearTimeout(this.timer);
|
|
83
|
+
const groups = this.groups;
|
|
84
|
+
const outputCount = groups.length;
|
|
85
|
+
this.groups = [];
|
|
86
|
+
this.sameKeyGroups = new Map();
|
|
87
|
+
this.runGroups = new Map();
|
|
88
|
+
this.timer = null;
|
|
89
|
+
this.pendingItems = 0;
|
|
90
|
+
this.outputs += outputCount;
|
|
91
|
+
this.flushes += 1;
|
|
92
|
+
if (reason === 'capacity') this.capacityFlushes += 1;
|
|
93
|
+
this.emit(groups);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 结束指定事件流的当前连续段;既有组仍等待原窗口冲刷。 */
|
|
97
|
+
breakRun(scope: string): void {
|
|
98
|
+
this.runGroups.delete(scope);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
reset(): void {
|
|
102
|
+
if (this.timer) clearTimeout(this.timer);
|
|
103
|
+
this.groups = [];
|
|
104
|
+
this.sameKeyGroups = new Map();
|
|
105
|
+
this.runGroups = new Map();
|
|
106
|
+
this.timer = null;
|
|
107
|
+
this.pendingItems = 0;
|
|
108
|
+
this.inputs = 0;
|
|
109
|
+
this.outputs = 0;
|
|
110
|
+
this.flushes = 0;
|
|
111
|
+
this.capacityFlushes = 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
state(): CoalescingBufferState {
|
|
115
|
+
return {
|
|
116
|
+
inputs: this.inputs,
|
|
117
|
+
outputs: this.outputs,
|
|
118
|
+
collapsed: this.inputs - this.outputs - this.groups.length,
|
|
119
|
+
flushes: this.flushes,
|
|
120
|
+
capacityFlushes: this.capacityFlushes,
|
|
121
|
+
pendingItems: this.pendingItems,
|
|
122
|
+
pendingGroups: this.groups.length,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|