dsh-mini-remote 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/lib/store.js ADDED
@@ -0,0 +1,334 @@
1
+ /**
2
+ * 插件端状态:最新一条回复、历史记录、会话绑定、手机在线状态。
3
+ *
4
+ * 内存为准,落盘只是为了重启后手机刷新还能看到东西。用 JSON 而不是 SQLite——
5
+ * 这点数据量(默认 50 条)不值得引依赖。
6
+ */
7
+ import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
8
+ import { join, dirname } from 'node:path'
9
+
10
+ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
11
+ const state = {
12
+ /**
13
+ * 单帧模式的数据源:**每个会话各自一条**,sessionId -> { text, title, timestamp, reason }。
14
+ *
15
+ * 原来这里是全局一个 latest,于是切到别的会话时,屏幕上还是上一个会话的答案
16
+ * (2026-09-21 用户实机报的:「切到一个正在执行中的会话,显示的却是之前会话的回答」)。
17
+ * 和 running 是同一个病根——**显示状态不能跨会话串**。
18
+ */
19
+ latestBySession: {},
20
+ /** 聊天模式的数据源,同样按会话分开。 */
21
+ historyBySession: {}, // sessionId -> [{ role, text, timestamp, sessionId }]
22
+ /** 手机当前遥控的那个会话。 */
23
+ boundSessionId: null,
24
+ /** 手机会话在线状态,用于决定要不要发外部推送。 */
25
+ presence: 'foreground',
26
+ lastSeen: 0,
27
+ /**
28
+ * 正在流出来的那一段:sessionId -> string。**故意不落盘**——它是过眼云烟,
29
+ * 存下来只会在下次启动时冒出一段没头没尾的半句话。
30
+ *
31
+ * 为什么要自己留一份:DSH 那条增量事件是**进程本地**的,不重放。手机中途刷新
32
+ * 或重连时,已经流过的部分再也拿不到,界面会突然空白一下。留一份塞进快照里,
33
+ * 任何一条广播都能把它补上。
34
+ */
35
+ liveBySession: {},
36
+ }
37
+
38
+ /** 已知会话的元信息:sessionId -> { id, title, running, lastActivity, seq } */
39
+ const sessions = new Map()
40
+
41
+ /**
42
+ * 单调递增的「谁更新」序号。
43
+ *
44
+ * 不能用 lastActivity(毫秒时间戳)来判最近活跃:两个会话在同一毫秒里被碰到时
45
+ * 时间戳相等,而 Map 的迭代顺序是**插入顺序**,于是先插入的那个会赢——
46
+ * 「最近活跃」就成了错的。这不是理论问题:`session/event` 来得很密,
47
+ * 同毫秒完全可能。而 index.js 用它决定手机在看哪个会话,判错就会串内容。
48
+ */
49
+ let touchSeq = 0
50
+
51
+ /** 最近活跃的会话——手机没绑定时默认遥控它。 */
52
+ function mostRecentSession() {
53
+ let best = null
54
+ for (const s of sessions.values()) {
55
+ if (!best || s.seq > best.seq) best = s
56
+ }
57
+ return best
58
+ }
59
+
60
+ /**
61
+ * 手机此刻在看哪个会话:绑定了认绑定,没绑过才退回最近活跃的。
62
+ *
63
+ * 这里和 index.js 的 targetSessionId() 是同一套口径。那边多一层「问 agents 注册表
64
+ * 要一个」的兜底(插件是热加载进来的、或者会话早就开着了,事件流里一次都没见过),
65
+ * 但两者都从这张 sessions 表取事实,所以不会打架。
66
+ */
67
+ function targetSessionId() {
68
+ if (state.boundSessionId) return state.boundSessionId
69
+ return mostRecentSession()?.id ?? null
70
+ }
71
+
72
+ /**
73
+ * 「队列里还排着什么」从哪儿问。
74
+ *
75
+ * 做成一个回调,而不是让 store 自己记一份,是因为队列的**真相在 agent 的收件箱里**
76
+ * (`agent.inbox.nextTurn`),而 store 拿不到 ctx。每次取快照现读一遍,
77
+ * 就不存在「手机上显示的队列和电脑上不一致」这种事——根本没有第二份状态可以漂移。
78
+ *
79
+ * 默认空实现:测试和 headless 组合里可能没有 agent 注册表,那时就是「队列是空的」。
80
+ */
81
+ let queueSource = () => []
82
+
83
+ function setQueueSource(fn) {
84
+ queueSource = typeof fn === 'function' ? fn : () => []
85
+ }
86
+
87
+ /** 某个会话的最新回复;没有就是 null。 */
88
+ function latestOf(sessionId) {
89
+ return sessionId ? (state.latestBySession[sessionId] ?? null) : null
90
+ }
91
+
92
+ /** 某个会话的聊天记录;没有就是空数组。 */
93
+ function historyOf(sessionId) {
94
+ return sessionId ? (state.historyBySession[sessionId] ?? []) : []
95
+ }
96
+
97
+ /**
98
+ * 正在流出来的那一段。空字符串表示「这会儿没有东西在流」。
99
+ *
100
+ * 只在**已经决定要显示**之后才写进来。服务端会先按兵不动一小会儿再决定露不露
101
+ * (理由见 lib/index.js 的 STREAM_GRACE_MS),所以这里为空不代表模型没在写,
102
+ * 只代表「还不到显示的时候」。
103
+ */
104
+ function liveOf(sessionId) {
105
+ return sessionId ? (state.liveBySession[sessionId] ?? '') : ''
106
+ }
107
+
108
+ /**
109
+ * 只保留最近碰过的若干会话的数据,免得这个 JSON 文件无限长大。
110
+ * 被丢掉的会话切过去会显示「还没有回复」——**这比显示别人的回答强**。
111
+ */
112
+ function trimSessions() {
113
+ const ids = Object.keys(state.historyBySession)
114
+ if (ids.length <= maxSessions) return
115
+ ids.sort((a, b) => (sessions.get(b)?.seq ?? 0) - (sessions.get(a)?.seq ?? 0))
116
+ for (const id of ids.slice(maxSessions)) {
117
+ delete state.historyBySession[id]
118
+ delete state.latestBySession[id]
119
+ delete state.liveBySession[id]
120
+ }
121
+ }
122
+
123
+ function pushHistory(sessionId, entry) {
124
+ let list = state.historyBySession[sessionId]
125
+ if (!list) list = state.historyBySession[sessionId] = []
126
+ list.push(entry)
127
+ if (list.length > maxHistory) list.splice(0, list.length - maxHistory)
128
+ trimSessions()
129
+ }
130
+
131
+ function load() {
132
+ try {
133
+ const raw = JSON.parse(readFileSync(file, 'utf8'))
134
+ if (raw && typeof raw === 'object') {
135
+ if (raw.latestBySession && typeof raw.latestBySession === 'object') {
136
+ state.latestBySession = raw.latestBySession
137
+ }
138
+ if (raw.historyBySession && typeof raw.historyBySession === 'object') {
139
+ for (const [id, list] of Object.entries(raw.historyBySession)) {
140
+ if (Array.isArray(list)) state.historyBySession[id] = list.slice(-maxHistory)
141
+ }
142
+ }
143
+ // 迁移旧格式(全局一个 latest / history)。按条目里记的 sessionId 归位,
144
+ // 归不了的丢掉——总比塞给错误的会话强。
145
+ if (raw.latest?.sessionId && !state.latestBySession[raw.latest.sessionId]) {
146
+ state.latestBySession[raw.latest.sessionId] = raw.latest
147
+ }
148
+ if (Array.isArray(raw.history)) {
149
+ for (const m of raw.history) {
150
+ if (m?.sessionId) pushHistory(m.sessionId, m)
151
+ }
152
+ }
153
+ if (raw.boundSessionId) state.boundSessionId = raw.boundSessionId
154
+ }
155
+ } catch {
156
+ // 首次运行没有文件,或文件损坏——都不该让插件起不来。
157
+ }
158
+ }
159
+
160
+ let flushTimer = null
161
+ function persist() {
162
+ if (flushTimer) return
163
+ // 事件流可能很密(每个 step 都有事件),合并写盘,避免拖慢 agent。
164
+ flushTimer = setTimeout(() => {
165
+ flushTimer = null
166
+ try {
167
+ mkdirSync(dirname(file), { recursive: true })
168
+ const tmp = `${file}.tmp`
169
+ writeFileSync(tmp, JSON.stringify({
170
+ latestBySession: state.latestBySession,
171
+ historyBySession: state.historyBySession,
172
+ boundSessionId: state.boundSessionId,
173
+ }))
174
+ renameSync(tmp, file)
175
+ } catch {
176
+ // 落盘失败不影响本次运行。
177
+ }
178
+ }, 500)
179
+ if (flushTimer.unref) flushTimer.unref()
180
+ }
181
+
182
+ load()
183
+
184
+ return {
185
+ state,
186
+ sessions,
187
+
188
+ /** 会话出现在事件流里时登记/更新。 */
189
+ touchSession(sessionId, patch = {}) {
190
+ let s = sessions.get(sessionId)
191
+ if (!s) {
192
+ s = { id: sessionId, title: '', running: false, lastActivity: 0, seq: 0 }
193
+ sessions.set(sessionId, s)
194
+ }
195
+ Object.assign(s, patch, { lastActivity: Date.now(), seq: ++touchSeq })
196
+ return s
197
+ },
198
+
199
+ forgetSession(sessionId) {
200
+ sessions.delete(sessionId)
201
+ },
202
+
203
+ setRunning(sessionId, running) {
204
+ const s = this.touchSession(sessionId, {})
205
+ s.running = running
206
+ },
207
+
208
+ /** 最近活跃的会话——手机没绑定时默认遥控它。 */
209
+ mostRecentSession,
210
+
211
+ /** 手机此刻在看哪个会话。 */
212
+ targetSessionId,
213
+
214
+ /** 告诉 store「队列里有什么」该去哪儿问(见 setQueueSource)。 */
215
+ setQueueSource,
216
+
217
+ /** 某个会话的最新回复 / 聊天记录。 */
218
+ latestOf,
219
+ historyOf,
220
+
221
+ bind(sessionId) {
222
+ state.boundSessionId = sessionId
223
+ persist()
224
+ },
225
+
226
+ /**
227
+ * Agent 产出了最终回复。只写进**这个会话**自己的那一份。
228
+ *
229
+ * interrupted 是「这一轮被中止了,下面只是已经吐出来的那半句」。
230
+ * 手机要把它标出来——不标的话,按了停止之后冒出来的半句话看着像模型答崩了。
231
+ */
232
+ pushReply({ text, sessionId, reason, interrupted }) {
233
+ const timestamp = Date.now()
234
+ const session = sessions.get(sessionId)
235
+ state.latestBySession[sessionId] = {
236
+ text, sessionId, title: session?.title ?? '', timestamp, reason,
237
+ interrupted: interrupted === true,
238
+ }
239
+ pushHistory(sessionId, {
240
+ role: 'assistant', text, timestamp, sessionId, interrupted: interrupted === true,
241
+ })
242
+ persist()
243
+ },
244
+
245
+ /** 用户(手机上或电脑上)发出了一条指令。 */
246
+ pushUser({ text, sessionId, id }) {
247
+ // id 要留着:排队中的指令靠它和 agent 收件箱里的那一条对上号,
248
+ // 界面上才能标出「这条还在排队」、也才能撤掉它。
249
+ pushHistory(sessionId, { role: 'user', text, timestamp: Date.now(), sessionId, id })
250
+ persist()
251
+ },
252
+
253
+ /**
254
+ * 清空**当前会话**在单帧模式里的内容。
255
+ *
256
+ * **目前没有任何地方调用它**,是历史遗留(清理逻辑当初写在手机端,见 page.html
257
+ * 的 send())。而且 2026-09-21 之后,清空这件事本身就是错的方向:用户要的是
258
+ * 发完指令后上一条回答**留着**,在它下面加一条「正在执行」。别为了「修」什么
259
+ * 把它接上去。
260
+ */
261
+ clearLatest() {
262
+ const id = targetSessionId()
263
+ if (id) delete state.latestBySession[id]
264
+ persist()
265
+ },
266
+
267
+ markSeen(presence) {
268
+ state.lastSeen = Date.now()
269
+ if (presence) state.presence = presence
270
+ },
271
+
272
+ /**
273
+ * 记下「正在流出来的那一段」。传空字符串就是收工。
274
+ *
275
+ * **不 persist()**:这是几秒钟的临时状态,写盘只是白费 I/O,还会在下次启动时
276
+ * 留下一段没头没尾的半句话。快照和广播都从内存里现读。
277
+ */
278
+ setLive(sessionId, text) {
279
+ if (!sessionId) return
280
+ if (text) state.liveBySession[sessionId] = text
281
+ else delete state.liveBySession[sessionId]
282
+ },
283
+
284
+ /** 手机是否真的在看着——决定要不要额外发系统推送。 */
285
+ isForeground() {
286
+ return state.presence === 'foreground' && Date.now() - state.lastSeen < 60_000
287
+ },
288
+
289
+ snapshot() {
290
+ const target = targetSessionId()
291
+ return {
292
+ /**
293
+ * latest 和 history 都是**当前这个会话**的那一份。
294
+ *
295
+ * 原来是全局一份,于是切到别的会话时,单帧模式里还显示着上一个会话的答案
296
+ * (2026-09-21 用户实机报的)。和 running 一样,显示状态不能跨会话串。
297
+ */
298
+ latest: latestOf(target),
299
+ history: historyOf(target),
300
+ /**
301
+ * 正在流出来的那一段(可能为空)。
302
+ *
303
+ * 跟着会话走,和 latest/history 同一条口径。手机把它显示在答案区、但**不**
304
+ * 当成答案——真正的答案还是要等不含工具调用的那一步落定(见 events.js)。
305
+ */
306
+ live: liveOf(target),
307
+ boundSessionId: state.boundSessionId,
308
+ /**
309
+ * 「正在执行」必须跟着会话走。
310
+ *
311
+ * 原来它只从 SSE 的 status 事件推过来,于是有两个都实测到的毛病:
312
+ * 在手机上切到别的会话,那个会话明明闲着也显示「正在执行」(切会话时
313
+ * 没人重算过它);页面刷新时如果任务正在跑,又反过来显示「已连接」。
314
+ * 放进快照里,**任何一条广播都能把它纠正回来**,两个毛病一起没了。
315
+ */
316
+ running: target ? (sessions.get(target)?.running ?? false) : false,
317
+ /**
318
+ * 排在队里、还没轮到的指令。
319
+ *
320
+ * 手机上跑着任务时接着发,那条不会丢,而是排进 agent 的收件箱
321
+ * (`followup()` 的语义就是「排队一个后续轮次」),等当前这一轮结束再跑。
322
+ * 既然收下了,就得让用户看见它排在哪——不然和「发出去了但没反应」没区别。
323
+ *
324
+ * 每次现读,不缓存:见 setQueueSource 的说明。
325
+ */
326
+ queued: target ? queueSource(target) : [],
327
+ // 同毫秒时用 seq 兜底,顺序才是确定的
328
+ sessions: [...sessions.values()].sort(
329
+ (a, b) => (b.lastActivity - a.lastActivity) || (b.seq - a.seq),
330
+ ),
331
+ }
332
+ },
333
+ }
334
+ }
package/lib/tree.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * 工作区 → 会话 的树,给手机左侧导航栏用。
3
+ *
4
+ * 数据来自两个服务,都在 web 组合里,headless 组合里没有:
5
+ * - workspaceRegistry.list() 同步。工作区的顺序、每个工作区下有哪些会话,
6
+ * 以及会话的排列顺序,全在这里。桌面端左侧栏的
7
+ * 分组用的就是它。
8
+ * - sessionQuery.listSessions() 异步。会话的事实:id、创建时间、cwd、origin。
9
+ * 注意它**不含标题**,标题要另外去读日志。
10
+ *
11
+ * 两个都不是必需的:拿不到就退回「只知道当前活跃会话」的老样子,不报错。
12
+ *
13
+ * 关于规模:本机实测 35 个工作区,最多的一个有 452 个会话。所以这里所有查询
14
+ * 都是**按需 + 限量**的——打开导航栏只列工作区(便宜),展开某个工作区才去取
15
+ * 它的会话(要读日志折标题,贵)。
16
+ */
17
+ import { basename } from 'node:path'
18
+
19
+ /** 一个工作区默认最多列多少会话。手机上再多也划不动。 */
20
+ export const SESSIONS_PER_WORKSPACE = 20
21
+
22
+ function safeList(registry) {
23
+ if (!registry) return []
24
+ try {
25
+ return registry.list() ?? []
26
+ } catch (err) {
27
+ // 服务还没启动时 requireState() 会抛。导航栏少几项,不该影响遥控本身。
28
+ return []
29
+ }
30
+ }
31
+
32
+ /**
33
+ * `listSessions()` 很贵:它要扫盘列出所有持久化的会话,再给每条记录做一次
34
+ * `structuredClone`。本机几百个会话,实测这个接口要 **850 毫秒左右**。
35
+ *
36
+ * 而导航栏会反复问同一个问题——打开一次、展开一个工作区、切回来再看一眼,
37
+ * 每次都问。用户报的「打开导航栏似乎每次都要重新读取」,根源就是这一秒。
38
+ *
39
+ * 所以在这里缓存一份。会话列表晚 30 秒知道不算错(手机上你本来也是手动刷新的),
40
+ * 真想立刻看到最新的,按导航栏的 ⟳,它带 `refresh=1` 绕过缓存。
41
+ */
42
+ const CACHE_TTL_MS = 30_000
43
+ /**
44
+ * query 服务 → { at, records, inflight }。
45
+ *
46
+ * **用 WeakMap 而不是一个模块级变量**:缓存必须认得出「这份数据是谁的」。
47
+ * 写成一个全局变量时,服务实例一换(插件重载、测试里每个用例各给一个桩),
48
+ * 新来的调用方就会拿到上一个实例的数据——而且不报错,只是安静地串味。
49
+ * 测试就是这么把它逮住的:五个用例突然读到了别的用例的工作区。
50
+ */
51
+ const caches = new WeakMap()
52
+
53
+ async function safeRecords(query, { force = false } = {}) {
54
+ if (!query) return []
55
+ let entry = caches.get(query)
56
+ if (!entry) {
57
+ entry = { at: 0, records: null, inflight: null }
58
+ caches.set(query, entry)
59
+ }
60
+ if (!force && entry.records && Date.now() - entry.at < CACHE_TTL_MS) return entry.records
61
+ // 同一时刻来两个请求就共用一趟,别让它们各扫一遍盘。
62
+ if (entry.inflight) return entry.inflight
63
+ entry.inflight = (async () => {
64
+ try {
65
+ const records = (await query.listSessions()) ?? []
66
+ entry.at = Date.now()
67
+ entry.records = records
68
+ return records
69
+ } catch (err) {
70
+ // 读不到就当作没有:导航栏少几项,不该影响遥控本身。失败不进缓存,下次还会重试。
71
+ return []
72
+ } finally {
73
+ entry.inflight = null
74
+ }
75
+ })()
76
+ return entry.inflight
77
+ }
78
+
79
+ function toRecordMap(records) {
80
+ const map = new Map()
81
+ for (const r of records) {
82
+ if (r?.header?.id) map.set(r.header.id, r)
83
+ }
84
+ return map
85
+ }
86
+
87
+ /** 子 agent 的会话不进手机——用户遥控的是自己的会话,不是它派出去的小弟。 */
88
+ function visible(record) {
89
+ return Boolean(record) && record.header.origin !== 'subagent'
90
+ }
91
+
92
+ /**
93
+ * 把标题从服务返回的形状里抠出来。
94
+ *
95
+ * 坑在这里:`readTitleSnapshots()` 给的**不是字符串**,而是一个「标题快照」对象
96
+ * `{ title, messageSeqs, source, eventSeq, updatedAt }`(见 dsh-session-title 的
97
+ * `titleSnapshotFromState`)。真正那句话在 `.title` 里。
98
+ * 而 `readTitle()` 反而直接返回字符串——同一件事的两个接口形状不一样。
99
+ *
100
+ * 不处理的话界面会显示 `[object Object]`(真机上就是这么发现的)。
101
+ * 两种形状都收,免得以后哪边改了又炸。
102
+ */
103
+ function titleText(value) {
104
+ if (typeof value === 'string') return value
105
+ if (value && typeof value === 'object' && typeof value.title === 'string') return value.title
106
+ return ''
107
+ }
108
+
109
+ /**
110
+ * 工作区列表。只给「有会话可切」的那些,空工作区不占地方。
111
+ * 返回顺序就是桌面端的显示顺序。
112
+ */
113
+ export async function listWorkspaces({ registry, query, runningIds, force }) {
114
+ const byId = toRecordMap(await safeRecords(query, { force }))
115
+ const out = []
116
+ for (const ws of safeList(registry)) {
117
+ const ids = (ws.sessionIds ?? []).filter((id) => visible(byId.get(id)))
118
+ if (!ids.length) continue
119
+ let running = 0
120
+ for (const id of ids) if (runningIds?.has(id)) running += 1
121
+ out.push({
122
+ id: ws.id,
123
+ // title 可能为空,退回到目录名——总比一个 UUID 强
124
+ title: (ws.title || '').trim() || basename(ws.path || '') || '未命名工作区',
125
+ path: ws.path || '',
126
+ count: ids.length,
127
+ running,
128
+ })
129
+ }
130
+ return out
131
+ }
132
+
133
+ /**
134
+ * 某个工作区下的会话,新的在前,最多 limit 条。
135
+ *
136
+ * 标题分两步拿:插件自己在事件流里见过的(内存里就有,免费)优先;
137
+ * 剩下的冷会话才去读日志。**只在展开一个工作区时才付这个代价**,所以有上限。
138
+ */
139
+ export async function listSessionsOf({ registry, query, agents, knownTitles, workspaceId, limit = SESSIONS_PER_WORKSPACE, force }) {
140
+ const ws = safeList(registry).find((w) => w.id === workspaceId)
141
+ if (!ws) return null
142
+
143
+ const byId = toRecordMap(await safeRecords(query, { force }))
144
+ const rows = (ws.sessionIds ?? [])
145
+ .map((id) => byId.get(id))
146
+ .filter(visible)
147
+ .sort((a, b) => (b.header.createdAt ?? 0) - (a.header.createdAt ?? 0))
148
+ .slice(0, limit)
149
+
150
+ // 内存里已知的标题先用着,缺的再去读日志。
151
+ const titles = new Map()
152
+ const missing = []
153
+ for (const r of rows) {
154
+ const known = knownTitles?.get(r.header.id)
155
+ if (known) titles.set(r.header.id, known)
156
+ else missing.push(r.header.id)
157
+ }
158
+ if (missing.length && query?.readTitleSnapshots) {
159
+ try {
160
+ for (const item of await query.readTitleSnapshots(missing)) {
161
+ const title = item?.status === 'fulfilled' ? titleText(item.value?.title) : ''
162
+ if (title) titles.set(item.sessionId, title)
163
+ }
164
+ } catch (err) {
165
+ // 读不出来就没有标题,界面会退回用日期,不影响切换。
166
+ }
167
+ }
168
+
169
+ return {
170
+ workspaceId,
171
+ total: (ws.sessionIds ?? []).length,
172
+ truncated: (ws.sessionIds ?? []).length > rows.length,
173
+ sessions: rows.map((r) => {
174
+ const id = r.header.id
175
+ const status = agents?.get(id)?.status
176
+ return {
177
+ id,
178
+ title: titles.get(id) ?? '',
179
+ createdAt: r.header.createdAt ?? 0,
180
+ running: status === 'running',
181
+ live: Boolean(r.live),
182
+ }
183
+ }),
184
+ }
185
+ }
package/lib/tunnel.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * 起一条 cloudflared 快速隧道,把本机的手机服务暴露到一个公网网址。
3
+ *
4
+ * 「快速隧道」是 Cloudflare 提供的免账号模式:跑起来就给你一个
5
+ * `https://<随机词>.trycloudflare.com` 的地址,转发回本机。代价是**这个网址
6
+ * 每次重启都会换一个**,所以手机上的旧链接会失效,得重新扫一次二维码。
7
+ */
8
+ import { spawn } from 'node:child_process'
9
+
10
+ /**
11
+ * 从 cloudflared 的输出里挑出公网网址。
12
+ *
13
+ * 有个坑:输出里会**先**出现 `https://api.trycloudflare.com`——那是它向
14
+ * Cloudflare 注册时用的接口地址,不是给你的网址。不加排除就会把用户送到一个
15
+ * 打不开的地址上。所以用负向前瞻把 `api.` 开头的排掉。
16
+ *
17
+ * 这个坑不是我们自己踩出来的,是从 dsh-pocket(shaobeichen,GPL-2.0)的注释里知道的,
18
+ * 它记在自己的 issue #32。**知道一个坑和抄一段代码是两回事**——这个正则、这里的函数
19
+ * 划分、以及这段说明都是我们自己写的。README 的「致谢」一节写明了这层关系。
20
+ */
21
+ export const QUICK_TUNNEL_URL_RE = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com/i
22
+
23
+ /**
24
+ * 公网隧道失败时,告诉用户「接下来做什么」。
25
+ *
26
+ * 2026-09-22 实测:本机代理的 **TUN 模式**(Clash 这类)会掐断 cloudflared 到 Cloudflare
27
+ * 边缘的连接——cloudflared 好好活着,却一条 ESTABLISHED 都没有;DNS 还被劫持成
28
+ * `198.18.x.x` 这种假地址(RFC 2544 保留段,真实世界不存在),走假地址访问隧道,
29
+ * Cloudflare 回 530(也就是用户看到的 Error 1033)。
30
+ *
31
+ * 这个坑 dsh-pocket 也踩过,它的报错文案里就写着「是否开着代理/VPN(Clash 等 TUN 模式
32
+ * 会掐断隧道连接)」。它没有解决,只是把它写进报错——因为**用户看到「连不上」的第一
33
+ * 反应是重试,而正确的动作往往是关代理**。所以这句必须写出来,不能省。
34
+ *
35
+ * 顺带指一条走得通的路:这个插件本来就有 Tailscale 那条,不必在 Cloudflare 上死磕。
36
+ *
37
+ * 放在这里(而不是 index.js 里)是为了能单测:文案本身就是这次的交付物,
38
+ * 少写一句「关代理」用户就会一直重试,值得钉住。
39
+ */
40
+ export const TUNNEL_HINT = '如果电脑上开着代理或 VPN(Clash 这类),先关掉再试一次——它们的'
41
+ + '「TUN 模式」会把隧道连接掐断。关掉还不行,就说明这条网络走不通 Cloudflare,'
42
+ + '改用设置页里的 Tailscale 那条(出门在外它更稳)。'
43
+
44
+ /**
45
+ * 从一段输出里挑出公网网址,没有就返回 null。
46
+ * 单独抽出来是为了能直接测——这段正则的坑(`api.trycloudflare.com` 要先出现)
47
+ * 值得用测试钉住,而不是靠真连一次 Cloudflare 去碰运气。
48
+ */
49
+ export function parseTunnelUrl(text) {
50
+ return QUICK_TUNNEL_URL_RE.exec(String(text ?? ''))?.[0] ?? null
51
+ }
52
+
53
+ /** 累积输出的上限。网址最多几十字节,留 8KB 足够,免得日志越攒越多。 */
54
+ const TAIL_LIMIT = 8000
55
+
56
+ const START_TIMEOUT_MS = 30_000
57
+
58
+ /**
59
+ * @param {object} opts
60
+ * @param {string} opts.binPath cloudflared 可执行文件
61
+ * @param {number} opts.port 本机手机服务的端口
62
+ * @returns {Promise<{url: string, stop: () => void, onExit: (cb: (code: number|null) => void) => () => void}>}
63
+ */
64
+ export function startTunnel({ binPath, port, log }) {
65
+ // --no-autoupdate 必须放在子命令**之前**:cloudflared 2026.x 去掉了 `tunnel`
66
+ // 子命令层级的这个参数,全局位置仍然有效。
67
+ // --protocol http2 是必要的:默认走 QUIC(UDP 7844),国内网络和不少企业网把
68
+ // UDP 封掉,表现是隧道报 error 1033 连不上;HTTP/2 走 TCP 443,基本都通。
69
+ // --edge-ip-version 4 同样是必要的,而且更隐蔽:隧道控制面 region1/region2.
70
+ // v2.argotunnel.com 在不少网络里**只解析出 IPv6**,而那条路往往不通。实测
71
+ // 不加这个参数时,cloudflared 要么卡在向 api.trycloudflare.com 注册(POST
72
+ // 超时),要么拿到网址却连不上边缘(公网访问回 HTTP 530);加上之后连接
73
+ // 立刻注册成功。走 IPv4 的代价是可以接受的——隧道两端都是 Cloudflare 的
74
+ // 任播地址,IPv4 覆盖没有短板。
75
+ const child = spawn(
76
+ binPath,
77
+ [
78
+ '--no-autoupdate',
79
+ 'tunnel',
80
+ '--url', `http://127.0.0.1:${port}`,
81
+ '--protocol', 'http2',
82
+ '--edge-ip-version', '4',
83
+ ],
84
+ { stdio: ['ignore', 'pipe', 'pipe'] },
85
+ )
86
+
87
+ const exitListeners = new Set()
88
+ const stop = () => {
89
+ try {
90
+ child.kill()
91
+ } catch {
92
+ // 已经退出了
93
+ }
94
+ }
95
+ const onExit = (cb) => {
96
+ exitListeners.add(cb)
97
+ return () => exitListeners.delete(cb)
98
+ }
99
+ child.on('exit', (code) => {
100
+ for (const cb of exitListeners) cb(code)
101
+ })
102
+
103
+ return new Promise((resolve, reject) => {
104
+ let settled = false
105
+ let tail = ''
106
+
107
+ const timer = setTimeout(() => {
108
+ if (settled) return
109
+ settled = true
110
+ stop()
111
+ reject(new Error(`等了 30 秒还没拿到公网网址。cloudflared 最后说的话:\n${tail.trim().slice(-500)}`))
112
+ }, START_TIMEOUT_MS)
113
+ timer.unref?.()
114
+
115
+ // 拿到网址之后仍然保持监听、只是不再处理——中途摘掉监听会让管道不再被消费,
116
+ // 64KB 缓冲填满后 cloudflared 会被卡住。
117
+ const onData = (chunk) => {
118
+ if (settled) return
119
+ // 要在**累积**的缓冲区里找,不能只看刚到这一块:网址有可能被拆成两次写出来。
120
+ tail = (tail + String(chunk)).slice(-TAIL_LIMIT)
121
+ const found = parseTunnelUrl(tail)
122
+ if (!found) return
123
+ settled = true
124
+ clearTimeout(timer)
125
+ log?.info?.(`公网地址:${found}`)
126
+ resolve({ url: found, stop, onExit })
127
+ }
128
+
129
+ child.stdout?.on('data', onData)
130
+ child.stderr?.on('data', onData)
131
+ child.on('error', (err) => {
132
+ if (settled) return
133
+ settled = true
134
+ clearTimeout(timer)
135
+ reject(new Error(`cloudflared 起不来:${err?.message ?? err}`))
136
+ })
137
+ child.on('exit', (code) => {
138
+ if (settled) return
139
+ settled = true
140
+ clearTimeout(timer)
141
+ reject(new Error(`cloudflared 提前退出了(退出码 ${code})。它最后说的话:\n${tail.trim().slice(-500)}`))
142
+ })
143
+ })
144
+ }