dsh-remote 0.8.71 → 0.8.73

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/lib/tasks.js CHANGED
@@ -1,95 +1,95 @@
1
- // dsh-remote — single-flight async task manager for long-running operations
2
- // (rw_sync / rw_push / rw_search with async:true). Tasks run one at a time
3
- // (the SSH pool is shared), report progress, and can be cancelled cooperatively.
4
-
5
- export class TaskManager {
6
- constructor() {
7
- this.tasks = new Map()
8
- this.nextId = 1
9
- this.queue = []
10
- this.running = false
11
- }
12
-
13
- /** Start a task. `fn(ctx)` where ctx = { progress(partial), cancelled }.
14
- * The task must poll ctx.cancelled between batches and return a result. */
15
- start(kind, label, fn) {
16
- const id = 'task-' + this.nextId++
17
- const task = {
18
- id,
19
- kind,
20
- label,
21
- status: 'queued',
22
- progress: {},
23
- result: null,
24
- error: null,
25
- startedAt: null,
26
- finishedAt: null,
27
- }
28
- this.tasks.set(id, task)
29
- this.queue.push({ task, fn })
30
- this._pump()
31
- return task
32
- }
33
-
34
- get(id) {
35
- return this.tasks.get(id) || null
36
- }
37
-
38
- list() {
39
- return [...this.tasks.values()].map((t) => ({
40
- id: t.id,
41
- kind: t.kind,
42
- label: t.label,
43
- status: t.status,
44
- progress: t.progress,
45
- error: t.error,
46
- startedAt: t.startedAt,
47
- finishedAt: t.finishedAt,
48
- }))
49
- }
50
-
51
- cancel(id) {
52
- const t = this.tasks.get(id)
53
- if (!t) return false
54
- if (t.status === 'queued') {
55
- t.status = 'cancelled'
56
- t.finishedAt = new Date().toISOString()
57
- const qi = this.queue.findIndex((q) => q.task === t)
58
- if (qi >= 0) this.queue.splice(qi, 1)
59
- return true
60
- }
61
- if (t.status === 'running') {
62
- t.cancelled = true
63
- return true
64
- }
65
- return false
66
- }
67
-
68
- async _pump() {
69
- if (this.running) return
70
- this.running = true
71
- try {
72
- while (this.queue.length) {
73
- const { task, fn } = this.queue.shift()
74
- if (task.status === 'cancelled') continue
75
- task.status = 'running'
76
- task.startedAt = new Date().toISOString()
77
- const ctx = {
78
- progress: (p) => { task.progress = { ...task.progress, ...p } },
79
- get cancelled() { return !!task.cancelled },
80
- }
81
- try {
82
- task.result = await fn(ctx)
83
- if (task.cancelled) task.status = 'cancelled'
84
- else task.status = 'done'
85
- } catch (err) {
86
- if (task.cancelled) task.status = 'cancelled'
87
- else { task.status = 'failed'; task.error = String((err && err.message) || err) }
88
- }
89
- task.finishedAt = new Date().toISOString()
90
- }
91
- } finally {
92
- this.running = false
93
- }
94
- }
95
- }
1
+ // dsh-remote — single-flight async task manager for long-running operations
2
+ // (rw_sync / rw_push / rw_search with async:true). Tasks run one at a time
3
+ // (the SSH pool is shared), report progress, and can be cancelled cooperatively.
4
+
5
+ export class TaskManager {
6
+ constructor() {
7
+ this.tasks = new Map()
8
+ this.nextId = 1
9
+ this.queue = []
10
+ this.running = false
11
+ }
12
+
13
+ /** Start a task. `fn(ctx)` where ctx = { progress(partial), cancelled }.
14
+ * The task must poll ctx.cancelled between batches and return a result. */
15
+ start(kind, label, fn) {
16
+ const id = 'task-' + this.nextId++
17
+ const task = {
18
+ id,
19
+ kind,
20
+ label,
21
+ status: 'queued',
22
+ progress: {},
23
+ result: null,
24
+ error: null,
25
+ startedAt: null,
26
+ finishedAt: null,
27
+ }
28
+ this.tasks.set(id, task)
29
+ this.queue.push({ task, fn })
30
+ this._pump()
31
+ return task
32
+ }
33
+
34
+ get(id) {
35
+ return this.tasks.get(id) || null
36
+ }
37
+
38
+ list() {
39
+ return [...this.tasks.values()].map((t) => ({
40
+ id: t.id,
41
+ kind: t.kind,
42
+ label: t.label,
43
+ status: t.status,
44
+ progress: t.progress,
45
+ error: t.error,
46
+ startedAt: t.startedAt,
47
+ finishedAt: t.finishedAt,
48
+ }))
49
+ }
50
+
51
+ cancel(id) {
52
+ const t = this.tasks.get(id)
53
+ if (!t) return false
54
+ if (t.status === 'queued') {
55
+ t.status = 'cancelled'
56
+ t.finishedAt = new Date().toISOString()
57
+ const qi = this.queue.findIndex((q) => q.task === t)
58
+ if (qi >= 0) this.queue.splice(qi, 1)
59
+ return true
60
+ }
61
+ if (t.status === 'running') {
62
+ t.cancelled = true
63
+ return true
64
+ }
65
+ return false
66
+ }
67
+
68
+ async _pump() {
69
+ if (this.running) return
70
+ this.running = true
71
+ try {
72
+ while (this.queue.length) {
73
+ const { task, fn } = this.queue.shift()
74
+ if (task.status === 'cancelled') continue
75
+ task.status = 'running'
76
+ task.startedAt = new Date().toISOString()
77
+ const ctx = {
78
+ progress: (p) => { task.progress = { ...task.progress, ...p } },
79
+ get cancelled() { return !!task.cancelled },
80
+ }
81
+ try {
82
+ task.result = await fn(ctx)
83
+ if (task.cancelled) task.status = 'cancelled'
84
+ else task.status = 'done'
85
+ } catch (err) {
86
+ if (task.cancelled) task.status = 'cancelled'
87
+ else { task.status = 'failed'; task.error = String((err && err.message) || err) }
88
+ }
89
+ task.finishedAt = new Date().toISOString()
90
+ }
91
+ } finally {
92
+ this.running = false
93
+ }
94
+ }
95
+ }
package/lib/telemetry.js CHANGED
@@ -1,213 +1,213 @@
1
- // dsh-remote — 安装级心跳(日活统计)。
2
- //
3
- // 目的:npm 下载量只能证明"有人下载",GitHub clone 里混着 CI 与爬虫,
4
- // 都无法回答"多少人真的在用、用的是哪一版、留在哪个平台"。这里上报一个
5
- // **最小字段的心跳**,让作者能算出去重日活 / 版本分布 / 平台分布 / 留存。
6
- //
7
- // ── 隐私边界(改这个文件前先读)──────────────────────────────────────────
8
- // 1) 上报的是伪名:`idHash = HMAC-SHA256(SALT, installId)[:32]`。
9
- // 原始 installId **绝不出机器**,服务端拿到的是本插件专属伪名,
10
- // 无法与其他数据交叉关联(installId 本身是随机 UUID,不含任何机器信息)。
11
- // 2) installId 存在 `<DSH_HOME>/.dsh-remote-install-id`,**不在插件目录**:
12
- // 插件目录会被 pnpm 重装覆盖,放在那里会让同一台机器每次升级都换身份,
13
- // 把 1 个人算成 N 个。DSH_HOME 跨重装稳定。
14
- // 3) 字段白名单固定为 {idHash, version, platform, arch, node}。
15
- // 本插件能拿到 SSH 主机、路径、机器列表——**这些一律不上报**。
16
- //
17
- // ── 失败策略 ─────────────────────────────────────────────────────────────
18
- // 全程静默:任何异常(网络不可达、磁盘只读、端点变更)都只吞掉,
19
- // 绝不阻塞加载、绝不写日志噪音、绝不重试轰炸。心跳是"尽力而为",
20
- // 它失败不能影响用户任何真实功能。
21
-
22
- import { createHmac, randomUUID } from 'node:crypto'
23
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
24
- import path from 'node:path'
25
-
26
- /** 上报端点(CloudBase HTTP 访问服务)。改域名只需改这一行。
27
- * 可用 `DSH_REMOTE_HEARTBEAT_URL` 覆盖——存在的理由不是"让用户改"
28
- * (默认行为不该需要配置),而是:① 测试必须能把它重定向掉,
29
- * 否则跑一次 npm test 就会往生产统计里灌假装机(发生过);
30
- * ② 作者换端点/灰度时不必为了改一行字符串发一次版。 */
31
- const DEFAULT_HEARTBEAT_URL = 'https://slippery-iguana-d1fhb1xmd469ee8c-1256429518.ap-shanghai.app.tcloudbase.com/dsh-hb/heartbeat'
32
-
33
- /** 解析实际使用的端点(env 覆盖优先,仅接受 https,避免被降级到明文)。 */
34
- export function heartbeatUrl() {
35
- const override = process.env.DSH_REMOTE_HEARTBEAT_URL
36
- if (override && /^https:\/\//i.test(override)) return override
37
- return DEFAULT_HEARTBEAT_URL
38
- }
39
- /** 伪名盐:服务端无法反推身份,且换盐即可切断历史关联。 */
40
- const SALT = 'dsh-remote/telemetry/v1'
41
- const ID_FILE = '.dsh-remote-install-id'
42
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
43
- /** 上报超时:心跳是旁路,不能拖慢启动;但也不能太短 —— 实测端点本身
44
- * 250~520ms,而 **DSH 启动瞬间的并发初始化会把首帧 fetch 饿到 5s 以上**
45
- * (曾以 5s 超时稳定失败在首启,靠下次启动才补上,等于丢掉当天最早的那批用户)。
46
- * 15s 覆盖启动抖动,且因为不 await,慢也不会影响加载。 */
47
- const TIMEOUT_MS = 15000
48
- /** 启动后延迟多久再发:避开启动高峰(插件树/路由/服务都在抢事件循环)。
49
- * 仍不 await——延迟是为了让心跳真的成功,不是为了让用户等待。 */
50
- const STARTUP_DELAY_MS = 3000
51
- /** 两次上报之间的最小间隔(同一天内重复启动不再上报)。 */
52
- const MIN_INTERVAL_MS = 6 * 60 * 60 * 1000
53
-
54
- /** 安装身份的进程内缓存,**按身份文件路径分键**。
55
- * ★ 不能只用一个全局变量:同一进程内出现第二个 DSH_HOME(隔离沙箱、
56
- * 测试夹具、desktop 与 cli 并存)时会串号,把 A 的 id 当成 B 的,
57
- * 日活与装机量一起失真。DSH 官方的 anonymous-user-id 同样是按路径 memo。 */
58
- const idCache = new Map()
59
-
60
- /**
61
- * 读取或创建本安装的随机身份(稳定 UUID,存 DSH_HOME)。
62
- * 磁盘只读等异常时返回内存生成的临时身份:日活会多算一次,但绝不报错。
63
- * @param home - DSH_HOME 绝对路径。
64
- */
65
- export function installId(home) {
66
- const file = path.join(home, ID_FILE)
67
- const cached = idCache.get(file)
68
- if (cached) return cached
69
-
70
- try {
71
- const existing = readFileSync(file, 'utf8').trim()
72
- if (UUID_RE.test(existing)) {
73
- idCache.set(file, existing)
74
- return existing
75
- }
76
- } catch { /* 不存在或不可读 → 下面创建 */ }
77
- const created = randomUUID()
78
- try {
79
- mkdirSync(home, { recursive: true })
80
- writeFileSync(file, `${created}\n`, 'utf8')
81
- } catch { /* 只读也不影响本次运行 */ }
82
- idCache.set(file, created)
83
- return created
84
- }
85
-
86
- /** 稳定的匿名伪名(32 位 hex)。原始 id 不离开本进程。 */
87
- export function pseudonym(home) {
88
- return createHmac('sha256', SALT).update(installId(home)).digest('hex').slice(0, 32)
89
- }
90
-
91
- /** 是否到该上报了(进程内节流,避免每次工具调用都发)。 */
92
- let lastSentAt = 0
93
-
94
- /** 是否处于测试进程。
95
- *
96
- * ★ 这是一道**代码级**兜底,不是便利设施。事故背景:光靠 package.json 里的
97
- * `--import ./test/setup-telemetry-off.mjs` 前置不够——它只覆盖 `npm test`,
98
- * 任何人直接 `node --test test/upload.test.js` 就会绕过它,而 upload.test.js
99
- * 会调用真实 apply(),于是**线上日活表被写进来自测试的假装机**
100
- * (实测:直接跑一次,云端立刻多一行;曾出现"只用过 Windows 的机器冒出 Linux 装机")。
101
- *
102
- * Node 的测试运行器会在**子进程**里设 `NODE_TEST_CONTEXT`(如 child-v8),
103
- * 父进程可能没有,所以同时看父进程的 `--test` 参数。判定为真时直接不发。
104
- * 代价:测试里也就无法验证"真的发出去了"——那正是本函数要保证的,
105
- * 真机端到端请用隔离沙箱启动真实 DSH(README 有说明)。
106
- */
107
- export function inTestProcess() {
108
- if (process.env.NODE_TEST_CONTEXT) return true
109
- const execArgv = process.execArgv || []
110
- if (execArgv.some((a) => a === '--test' || String(a).startsWith('--test-'))) return true
111
- return false
112
- }
113
-
114
- /**
115
- * 真正执行发送(不含"是否处于测试进程"的守卫)。
116
- *
117
- * 拆出来的原因:守卫本身必须可测,而"能测"又不能变成生产代码的后门——
118
- * 所以守卫只存在于 {@link sendHeartbeat} 里,本函数只被它与测试直接调用,
119
- * 且**不导出给业务代码**(导出名带下划线前缀,见文件末尾)。
120
- */
121
- async function deliver(home, version, options = {}) {
122
- const now = Date.now()
123
- if (now - lastSentAt < MIN_INTERVAL_MS) return false
124
-
125
- const delayMs = options.delayMs === undefined ? STARTUP_DELAY_MS : Math.max(0, options.delayMs)
126
- // 先占位,防止并发触发重复上报(本插件是单进程,够用)
127
- lastSentAt = now
128
- try {
129
- if (delayMs) await new Promise((r) => {
130
- // ★ 这里**不能 unref**。曾经 unref 过延迟定时器,结果是:await 一个已
131
- // unref 的定时器不会让事件循环保活,当进程恰好没有别的 pending 句柄时
132
- // Node 会**直接退出**,心跳一次都发不出去(CI 上稳定复现:本地因为其它
133
- // 测试还留着句柄而侥幸变绿)。延迟是为了让心跳成功,就必须让它把进程
134
- // 留住这几秒;插件真实运行时也不可能因为多等 3s 影响用户。
135
- setTimeout(r, delayMs)
136
- })
137
- const body = JSON.stringify({
138
- idHash: pseudonym(home),
139
- version: String(version || '0.0.0'),
140
- platform: process.platform,
141
- arch: process.arch,
142
- node: process.versions.node,
143
- })
144
- const controller = new AbortController()
145
- const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
146
- try {
147
- await fetch(heartbeatUrl(), {
148
- method: 'POST',
149
- headers: { 'content-type': 'application/json' },
150
- body,
151
- signal: controller.signal,
152
- })
153
- } finally {
154
- clearTimeout(timer)
155
- }
156
- return true
157
- } catch {
158
- // 网络不可达 / 端点变更 / 被拦截:一律忽略。
159
- // 允许下次重试:回滚节流,避免一次失败就把身份静默六个小时。
160
- lastSentAt = 0
161
- return false
162
- }
163
- }
164
-
165
- /**
166
- * 发送一次心跳。**永不抛出**,也永不返回可导致分支的失败态:
167
- * 调用方不需要 try/catch,心跳成功与否都不应改变任何行为。
168
- * @param home - DSH_HOME 绝对路径。
169
- * @param version - 运行中的插件版本(用 LOADED_VERSION,即真正在跑的代码版本)。
170
- * @param options.delayMs - 发送前延迟(默认 {@link STARTUP_DELAY_MS});
171
- * 避开启动高峰。测试可传 0。
172
- * @returns 是否真的发出了请求(仅用于测试与自检)。
173
- */
174
- export async function sendHeartbeat(home, version, options = {}) {
175
- // 测试进程一律不外呼:统计数据的可信度比"测试里能验证外呼"重要得多。
176
- if (inTestProcess()) return false
177
- return deliver(home, version, options)
178
- }
179
-
180
- /** 清空节流与缓存(仅测试用)。 */
181
- export function _resetForTest() {
182
- lastSentAt = 0
183
- idCache.clear()
184
- }
185
-
186
- /** 心跳是否已配置(供设置页显示;不含端点本身)。 */
187
- export function heartbeatEnabled() {
188
- return heartbeatUrl().startsWith('https://')
189
- }
190
-
191
- /** 身份文件是否已存在(自检用,不创建)。 */
192
- export function hasPersistedId(home) {
193
- try {
194
- return UUID_RE.test(readFileSync(path.join(home, ID_FILE), 'utf8').trim())
195
- } catch {
196
- return false
197
- }
198
- }
199
-
200
- /** 供自检:确认 installId 落盘位置(不创建)。 */
201
- export function installIdPath(home) {
202
- return path.join(home, ID_FILE)
203
- }
204
-
205
- /**
206
- * 测试专用入口:绕过"测试进程不外呼"的守卫,用来验证发送逻辑本身
207
- * (字段白名单、节流、失败回滚、延迟)。
208
- *
209
- * 之所以必须存在:守卫让 sendHeartbeat 在测试里恒返回 false,于是发送逻辑
210
- * 就完全测不到了。真正的安全来自**端点可覆盖**(测试把 URL 指向本地不可达地址)
211
- * 而不是来自这个函数——它只是把守卫那道门打开给测试用。
212
- */
213
- export const _deliverForTest = deliver
1
+ // dsh-remote — 安装级心跳(日活统计)。
2
+ //
3
+ // 目的:npm 下载量只能证明"有人下载",GitHub clone 里混着 CI 与爬虫,
4
+ // 都无法回答"多少人真的在用、用的是哪一版、留在哪个平台"。这里上报一个
5
+ // **最小字段的心跳**,让作者能算出去重日活 / 版本分布 / 平台分布 / 留存。
6
+ //
7
+ // ── 隐私边界(改这个文件前先读)──────────────────────────────────────────
8
+ // 1) 上报的是伪名:`idHash = HMAC-SHA256(SALT, installId)[:32]`。
9
+ // 原始 installId **绝不出机器**,服务端拿到的是本插件专属伪名,
10
+ // 无法与其他数据交叉关联(installId 本身是随机 UUID,不含任何机器信息)。
11
+ // 2) installId 存在 `<DSH_HOME>/.dsh-remote-install-id`,**不在插件目录**:
12
+ // 插件目录会被 pnpm 重装覆盖,放在那里会让同一台机器每次升级都换身份,
13
+ // 把 1 个人算成 N 个。DSH_HOME 跨重装稳定。
14
+ // 3) 字段白名单固定为 {idHash, version, platform, arch, node}。
15
+ // 本插件能拿到 SSH 主机、路径、机器列表——**这些一律不上报**。
16
+ //
17
+ // ── 失败策略 ─────────────────────────────────────────────────────────────
18
+ // 全程静默:任何异常(网络不可达、磁盘只读、端点变更)都只吞掉,
19
+ // 绝不阻塞加载、绝不写日志噪音、绝不重试轰炸。心跳是"尽力而为",
20
+ // 它失败不能影响用户任何真实功能。
21
+
22
+ import { createHmac, randomUUID } from 'node:crypto'
23
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
24
+ import path from 'node:path'
25
+
26
+ /** 上报端点(CloudBase HTTP 访问服务)。改域名只需改这一行。
27
+ * 可用 `DSH_REMOTE_HEARTBEAT_URL` 覆盖——存在的理由不是"让用户改"
28
+ * (默认行为不该需要配置),而是:① 测试必须能把它重定向掉,
29
+ * 否则跑一次 npm test 就会往生产统计里灌假装机(发生过);
30
+ * ② 作者换端点/灰度时不必为了改一行字符串发一次版。 */
31
+ const DEFAULT_HEARTBEAT_URL = 'https://slippery-iguana-d1fhb1xmd469ee8c-1256429518.ap-shanghai.app.tcloudbase.com/dsh-hb/heartbeat'
32
+
33
+ /** 解析实际使用的端点(env 覆盖优先,仅接受 https,避免被降级到明文)。 */
34
+ export function heartbeatUrl() {
35
+ const override = process.env.DSH_REMOTE_HEARTBEAT_URL
36
+ if (override && /^https:\/\//i.test(override)) return override
37
+ return DEFAULT_HEARTBEAT_URL
38
+ }
39
+ /** 伪名盐:服务端无法反推身份,且换盐即可切断历史关联。 */
40
+ const SALT = 'dsh-remote/telemetry/v1'
41
+ const ID_FILE = '.dsh-remote-install-id'
42
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
43
+ /** 上报超时:心跳是旁路,不能拖慢启动;但也不能太短 —— 实测端点本身
44
+ * 250~520ms,而 **DSH 启动瞬间的并发初始化会把首帧 fetch 饿到 5s 以上**
45
+ * (曾以 5s 超时稳定失败在首启,靠下次启动才补上,等于丢掉当天最早的那批用户)。
46
+ * 15s 覆盖启动抖动,且因为不 await,慢也不会影响加载。 */
47
+ const TIMEOUT_MS = 15000
48
+ /** 启动后延迟多久再发:避开启动高峰(插件树/路由/服务都在抢事件循环)。
49
+ * 仍不 await——延迟是为了让心跳真的成功,不是为了让用户等待。 */
50
+ const STARTUP_DELAY_MS = 3000
51
+ /** 两次上报之间的最小间隔(同一天内重复启动不再上报)。 */
52
+ const MIN_INTERVAL_MS = 6 * 60 * 60 * 1000
53
+
54
+ /** 安装身份的进程内缓存,**按身份文件路径分键**。
55
+ * ★ 不能只用一个全局变量:同一进程内出现第二个 DSH_HOME(隔离沙箱、
56
+ * 测试夹具、desktop 与 cli 并存)时会串号,把 A 的 id 当成 B 的,
57
+ * 日活与装机量一起失真。DSH 官方的 anonymous-user-id 同样是按路径 memo。 */
58
+ const idCache = new Map()
59
+
60
+ /**
61
+ * 读取或创建本安装的随机身份(稳定 UUID,存 DSH_HOME)。
62
+ * 磁盘只读等异常时返回内存生成的临时身份:日活会多算一次,但绝不报错。
63
+ * @param home - DSH_HOME 绝对路径。
64
+ */
65
+ export function installId(home) {
66
+ const file = path.join(home, ID_FILE)
67
+ const cached = idCache.get(file)
68
+ if (cached) return cached
69
+
70
+ try {
71
+ const existing = readFileSync(file, 'utf8').trim()
72
+ if (UUID_RE.test(existing)) {
73
+ idCache.set(file, existing)
74
+ return existing
75
+ }
76
+ } catch { /* 不存在或不可读 → 下面创建 */ }
77
+ const created = randomUUID()
78
+ try {
79
+ mkdirSync(home, { recursive: true })
80
+ writeFileSync(file, `${created}\n`, 'utf8')
81
+ } catch { /* 只读也不影响本次运行 */ }
82
+ idCache.set(file, created)
83
+ return created
84
+ }
85
+
86
+ /** 稳定的匿名伪名(32 位 hex)。原始 id 不离开本进程。 */
87
+ export function pseudonym(home) {
88
+ return createHmac('sha256', SALT).update(installId(home)).digest('hex').slice(0, 32)
89
+ }
90
+
91
+ /** 是否到该上报了(进程内节流,避免每次工具调用都发)。 */
92
+ let lastSentAt = 0
93
+
94
+ /** 是否处于测试进程。
95
+ *
96
+ * ★ 这是一道**代码级**兜底,不是便利设施。事故背景:光靠 package.json 里的
97
+ * `--import ./test/setup-telemetry-off.mjs` 前置不够——它只覆盖 `npm test`,
98
+ * 任何人直接 `node --test test/upload.test.js` 就会绕过它,而 upload.test.js
99
+ * 会调用真实 apply(),于是**线上日活表被写进来自测试的假装机**
100
+ * (实测:直接跑一次,云端立刻多一行;曾出现"只用过 Windows 的机器冒出 Linux 装机")。
101
+ *
102
+ * Node 的测试运行器会在**子进程**里设 `NODE_TEST_CONTEXT`(如 child-v8),
103
+ * 父进程可能没有,所以同时看父进程的 `--test` 参数。判定为真时直接不发。
104
+ * 代价:测试里也就无法验证"真的发出去了"——那正是本函数要保证的,
105
+ * 真机端到端请用隔离沙箱启动真实 DSH(README 有说明)。
106
+ */
107
+ export function inTestProcess() {
108
+ if (process.env.NODE_TEST_CONTEXT) return true
109
+ const execArgv = process.execArgv || []
110
+ if (execArgv.some((a) => a === '--test' || String(a).startsWith('--test-'))) return true
111
+ return false
112
+ }
113
+
114
+ /**
115
+ * 真正执行发送(不含"是否处于测试进程"的守卫)。
116
+ *
117
+ * 拆出来的原因:守卫本身必须可测,而"能测"又不能变成生产代码的后门——
118
+ * 所以守卫只存在于 {@link sendHeartbeat} 里,本函数只被它与测试直接调用,
119
+ * 且**不导出给业务代码**(导出名带下划线前缀,见文件末尾)。
120
+ */
121
+ async function deliver(home, version, options = {}) {
122
+ const now = Date.now()
123
+ if (now - lastSentAt < MIN_INTERVAL_MS) return false
124
+
125
+ const delayMs = options.delayMs === undefined ? STARTUP_DELAY_MS : Math.max(0, options.delayMs)
126
+ // 先占位,防止并发触发重复上报(本插件是单进程,够用)
127
+ lastSentAt = now
128
+ try {
129
+ if (delayMs) await new Promise((r) => {
130
+ // ★ 这里**不能 unref**。曾经 unref 过延迟定时器,结果是:await 一个已
131
+ // unref 的定时器不会让事件循环保活,当进程恰好没有别的 pending 句柄时
132
+ // Node 会**直接退出**,心跳一次都发不出去(CI 上稳定复现:本地因为其它
133
+ // 测试还留着句柄而侥幸变绿)。延迟是为了让心跳成功,就必须让它把进程
134
+ // 留住这几秒;插件真实运行时也不可能因为多等 3s 影响用户。
135
+ setTimeout(r, delayMs)
136
+ })
137
+ const body = JSON.stringify({
138
+ idHash: pseudonym(home),
139
+ version: String(version || '0.0.0'),
140
+ platform: process.platform,
141
+ arch: process.arch,
142
+ node: process.versions.node,
143
+ })
144
+ const controller = new AbortController()
145
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
146
+ try {
147
+ await fetch(heartbeatUrl(), {
148
+ method: 'POST',
149
+ headers: { 'content-type': 'application/json' },
150
+ body,
151
+ signal: controller.signal,
152
+ })
153
+ } finally {
154
+ clearTimeout(timer)
155
+ }
156
+ return true
157
+ } catch {
158
+ // 网络不可达 / 端点变更 / 被拦截:一律忽略。
159
+ // 允许下次重试:回滚节流,避免一次失败就把身份静默六个小时。
160
+ lastSentAt = 0
161
+ return false
162
+ }
163
+ }
164
+
165
+ /**
166
+ * 发送一次心跳。**永不抛出**,也永不返回可导致分支的失败态:
167
+ * 调用方不需要 try/catch,心跳成功与否都不应改变任何行为。
168
+ * @param home - DSH_HOME 绝对路径。
169
+ * @param version - 运行中的插件版本(用 LOADED_VERSION,即真正在跑的代码版本)。
170
+ * @param options.delayMs - 发送前延迟(默认 {@link STARTUP_DELAY_MS});
171
+ * 避开启动高峰。测试可传 0。
172
+ * @returns 是否真的发出了请求(仅用于测试与自检)。
173
+ */
174
+ export async function sendHeartbeat(home, version, options = {}) {
175
+ // 测试进程一律不外呼:统计数据的可信度比"测试里能验证外呼"重要得多。
176
+ if (inTestProcess()) return false
177
+ return deliver(home, version, options)
178
+ }
179
+
180
+ /** 清空节流与缓存(仅测试用)。 */
181
+ export function _resetForTest() {
182
+ lastSentAt = 0
183
+ idCache.clear()
184
+ }
185
+
186
+ /** 心跳是否已配置(供设置页显示;不含端点本身)。 */
187
+ export function heartbeatEnabled() {
188
+ return heartbeatUrl().startsWith('https://')
189
+ }
190
+
191
+ /** 身份文件是否已存在(自检用,不创建)。 */
192
+ export function hasPersistedId(home) {
193
+ try {
194
+ return UUID_RE.test(readFileSync(path.join(home, ID_FILE), 'utf8').trim())
195
+ } catch {
196
+ return false
197
+ }
198
+ }
199
+
200
+ /** 供自检:确认 installId 落盘位置(不创建)。 */
201
+ export function installIdPath(home) {
202
+ return path.join(home, ID_FILE)
203
+ }
204
+
205
+ /**
206
+ * 测试专用入口:绕过"测试进程不外呼"的守卫,用来验证发送逻辑本身
207
+ * (字段白名单、节流、失败回滚、延迟)。
208
+ *
209
+ * 之所以必须存在:守卫让 sendHeartbeat 在测试里恒返回 false,于是发送逻辑
210
+ * 就完全测不到了。真正的安全来自**端点可覆盖**(测试把 URL 指向本地不可达地址)
211
+ * 而不是来自这个函数——它只是把守卫那道门打开给测试用。
212
+ */
213
+ export const _deliverForTest = deliver