@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.4
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/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +579 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3938 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +408 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- package/tools/recover-queue.mjs +133 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang v2.6.5 — 增量 SSE 解析器(母体侧 SSE 传输的帧语法层)。
|
|
4
|
+
//
|
|
5
|
+
// 语义(与 docs/FUZZ-SSE-PARSER.md 的不变量一致):
|
|
6
|
+
// * 只按 `\n` 切行;行尾若有且仅有一个 `\r` 则裁掉(CRLF 支持,lone-CR 当作 data 字符);
|
|
7
|
+
// * 空行 = 帧边界:只有 dataLines 非空才产出帧(纯 id/event/注释帧被丢弃);
|
|
8
|
+
// * `:` 开头为注释(心跳),忽略;`id` / `event` / `data` 之外字段忽略(`retry` 也忽略);
|
|
9
|
+
// * 冒号后一个空格可选;多行 data 用 `\n` 拼接;dispatch 后重置 id/event/data;
|
|
10
|
+
// * flush() 丢弃未完成的残帧(EOF 语义),不产出帧。
|
|
11
|
+
export class SseParser {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.buffer = ''
|
|
14
|
+
this.curId = undefined
|
|
15
|
+
this.curEvent = undefined
|
|
16
|
+
this.dataLines = []
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 喂入一段字符串,返回本次能解析出的完整帧。
|
|
21
|
+
* @param {unknown} chunk
|
|
22
|
+
* @returns {Array<{id?: string, event?: string, data: string}>}
|
|
23
|
+
*/
|
|
24
|
+
push(chunk) {
|
|
25
|
+
this.buffer += String(chunk ?? '')
|
|
26
|
+
const frames = []
|
|
27
|
+
let start = 0
|
|
28
|
+
while (start < this.buffer.length) {
|
|
29
|
+
const nl = this.buffer.indexOf('\n', start)
|
|
30
|
+
if (nl === -1) break
|
|
31
|
+
let line = this.buffer.slice(start, nl)
|
|
32
|
+
if (line.endsWith('\r')) line = line.slice(0, -1)
|
|
33
|
+
start = nl + 1
|
|
34
|
+
const frame = this._processLine(line)
|
|
35
|
+
if (frame) frames.push(frame)
|
|
36
|
+
}
|
|
37
|
+
this.buffer = this.buffer.slice(start)
|
|
38
|
+
return frames
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 流结束:按 SSE 规范丢弃未完成的半帧(没有空行边界的 data 不 dispatch)。
|
|
43
|
+
* @returns {[]}
|
|
44
|
+
*/
|
|
45
|
+
flush() {
|
|
46
|
+
this.buffer = ''
|
|
47
|
+
this.curId = undefined
|
|
48
|
+
this.curEvent = undefined
|
|
49
|
+
this.dataLines = []
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @private */
|
|
54
|
+
_processLine(line) {
|
|
55
|
+
if (line === '') {
|
|
56
|
+
if (this.dataLines.length === 0) {
|
|
57
|
+
this.curId = undefined
|
|
58
|
+
this.curEvent = undefined
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
const frame = { data: this.dataLines.join('\n') }
|
|
62
|
+
if (this.curId !== undefined) frame.id = this.curId
|
|
63
|
+
if (this.curEvent !== undefined) frame.event = this.curEvent
|
|
64
|
+
this.curId = undefined
|
|
65
|
+
this.curEvent = undefined
|
|
66
|
+
this.dataLines = []
|
|
67
|
+
return frame
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (line.startsWith(':')) return null // 注释 / 心跳
|
|
71
|
+
|
|
72
|
+
const colon = line.indexOf(':')
|
|
73
|
+
let field = line
|
|
74
|
+
let value = ''
|
|
75
|
+
if (colon >= 0) {
|
|
76
|
+
field = line.slice(0, colon)
|
|
77
|
+
value = line.slice(colon + 1)
|
|
78
|
+
if (value.startsWith(' ')) value = value.slice(1)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
switch (field) {
|
|
82
|
+
case 'id':
|
|
83
|
+
this.curId = value
|
|
84
|
+
break
|
|
85
|
+
case 'event':
|
|
86
|
+
this.curEvent = value
|
|
87
|
+
break
|
|
88
|
+
case 'data':
|
|
89
|
+
this.dataLines.push(value)
|
|
90
|
+
break
|
|
91
|
+
default:
|
|
92
|
+
break // retry / 未知字段:忽略
|
|
93
|
+
}
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {object} [options] 保留参数位;返回新的 SseParser。 */
|
|
99
|
+
export function createSseParser(options = {}) {
|
|
100
|
+
void options
|
|
101
|
+
return new SseParser()
|
|
102
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
//
|
|
4
|
+
// ssrf-guard — AI / 出站端点的最小 SSRF 防护:
|
|
5
|
+
// * 字面量私网 IP:默认拒绝,除非显式 allowlist
|
|
6
|
+
// * 云元数据地址/主机名:永远拒绝(allowlist 也不能放行)
|
|
7
|
+
// * hostname:配置时只做语法检查;请求前做一次 DNS 解析,
|
|
8
|
+
// 解析到私网/元数据则拒绝(防“公网域名解析到内网”的常见 SSRF)
|
|
9
|
+
//
|
|
10
|
+
// 诚实边界:DNS 预检与 fetch 的真实解析之间仍有 TOCTOU 窗口(DNS rebinding),
|
|
11
|
+
// Node fetch 没有 pinned-lookup 接口;因此这里只做“高置信拦截”,不宣称完备。
|
|
12
|
+
import net from 'node:net'
|
|
13
|
+
import dns from 'node:dns/promises'
|
|
14
|
+
|
|
15
|
+
const METADATA_HOSTS = new Set([
|
|
16
|
+
'metadata.google.internal',
|
|
17
|
+
'metadata.goog',
|
|
18
|
+
'instance-data',
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
const METADATA_ADDRESSES = new Set([
|
|
22
|
+
'169.254.169.254', // AWS / GCP / Azure IMDS
|
|
23
|
+
'100.100.100.200', // Alibaba Cloud
|
|
24
|
+
'192.0.0.192', // Oracle Cloud
|
|
25
|
+
'fd00:ec2::254', // AWS IPv6 IMDS
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
export function normalizeHost(value) {
|
|
29
|
+
let host = String(value ?? '').trim().toLowerCase()
|
|
30
|
+
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1)
|
|
31
|
+
return host.replace(/\.$/, '')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isMetadataAddress(value) {
|
|
35
|
+
return METADATA_ADDRESSES.has(normalizeHost(value))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isMetadataHost(value) {
|
|
39
|
+
return METADATA_HOSTS.has(normalizeHost(value))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const inV4Range = (parts, base, bits) => {
|
|
43
|
+
if (!Array.isArray(parts) || parts.length !== 4) return false
|
|
44
|
+
let value = 0
|
|
45
|
+
for (const part of parts) value = (value << 8) | part
|
|
46
|
+
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0
|
|
47
|
+
return (value & mask) === (base & mask)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ipv4Parts(ip) {
|
|
51
|
+
const parts = ip.split('.').map(Number)
|
|
52
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null
|
|
53
|
+
return parts
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ipv4ToInt(parts) {
|
|
57
|
+
return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isPrivateIpv4(ip) {
|
|
61
|
+
const parts = ipv4Parts(ip)
|
|
62
|
+
if (!parts) return false
|
|
63
|
+
const value = ipv4ToInt(parts)
|
|
64
|
+
// RFC1918
|
|
65
|
+
if (inV4Range(parts, ipv4ToInt([10, 0, 0, 0]), 8)) return true
|
|
66
|
+
if (inV4Range(parts, ipv4ToInt([172, 16, 0, 0]), 12)) return true
|
|
67
|
+
if (inV4Range(parts, ipv4ToInt([192, 168, 0, 0]), 16)) return true
|
|
68
|
+
// loopback / link-local / unspecified
|
|
69
|
+
if (inV4Range(parts, ipv4ToInt([127, 0, 0, 0]), 8)) return true
|
|
70
|
+
if (inV4Range(parts, ipv4ToInt([169, 254, 0, 0]), 16)) return true
|
|
71
|
+
if (inV4Range(parts, ipv4ToInt([0, 0, 0, 0]), 8)) return true
|
|
72
|
+
// CGNAT / benchmark / multicast / reserved
|
|
73
|
+
if (inV4Range(parts, ipv4ToInt([100, 64, 0, 0]), 10)) return true
|
|
74
|
+
if (inV4Range(parts, ipv4ToInt([198, 18, 0, 0]), 15)) return true
|
|
75
|
+
if (inV4Range(parts, ipv4ToInt([198, 51, 100, 0]), 24)) return true
|
|
76
|
+
if (inV4Range(parts, ipv4ToInt([203, 0, 113, 0]), 24)) return true
|
|
77
|
+
if (((value & 0xF0000000) >>> 0) === 0xE0000000) return true // multicast
|
|
78
|
+
if (((value & 0xF0000000) >>> 0) === 0xF0000000) return true // reserved
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ipv6Groups(ip) {
|
|
83
|
+
const raw = normalizeHost(ip)
|
|
84
|
+
if (!raw.includes(':')) return null
|
|
85
|
+
const [head, tail] = raw.split('::')
|
|
86
|
+
const parse = (segment) => segment.length === 0 ? [] : segment.split(':')
|
|
87
|
+
let groups = []
|
|
88
|
+
if (raw.includes('::')) groups = [...parse(head), ...Array(8 - parse(head).length - parse(tail ?? '').length).fill('0'), ...parse(tail ?? '')]
|
|
89
|
+
else groups = raw.split(':')
|
|
90
|
+
if (groups.length !== 8) return null
|
|
91
|
+
return groups.map((group) => parseInt(group || '0', 16)).filter((part) => Number.isInteger(part))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function isPrivateIpv6(ip) {
|
|
95
|
+
const raw = normalizeHost(ip)
|
|
96
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(raw)
|
|
97
|
+
if (mapped) return isPrivateIpv4(mapped[1])
|
|
98
|
+
const groups = ipv6Groups(raw)
|
|
99
|
+
if (!groups) return false
|
|
100
|
+
if (groups.every((group, index) => (index < 7 ? group === 0 : group === 1))) return true // ::1
|
|
101
|
+
if (groups.every((group) => group === 0)) return true // ::
|
|
102
|
+
if ((groups[0] & 0xFE00) === 0xFC00) return true // fc00::/7
|
|
103
|
+
if ((groups[0] & 0xFFC0) === 0xFE80) return true // fe80::/10
|
|
104
|
+
if ((groups[0] & 0xFF00) === 0xFF00) return true // multicast
|
|
105
|
+
// IPv4-mapped ::ffff:a.b.c.d
|
|
106
|
+
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xFFFF) {
|
|
107
|
+
const v4 = `${groups[6] >> 8}.${groups[6] & 0xFF}.${groups[7] >> 8}.${groups[7] & 0xFF}`
|
|
108
|
+
return isPrivateIpv4(v4)
|
|
109
|
+
}
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isPrivateAddress(ip) {
|
|
114
|
+
const host = normalizeHost(ip)
|
|
115
|
+
const family = net.isIP(host)
|
|
116
|
+
if (family === 4) return isPrivateIpv4(host)
|
|
117
|
+
if (family === 6) return isPrivateIpv6(host)
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 同步分类:仅处理字面量 IP 与元数据主机名;DNS 主机名留给请求前预检。 */
|
|
122
|
+
export function classifyEndpointHost(hostname, { allowHosts = [] } = {}) {
|
|
123
|
+
const host = normalizeHost(hostname)
|
|
124
|
+
const allowed = new Set((Array.isArray(allowHosts) ? allowHosts : []).map(normalizeHost))
|
|
125
|
+
if (isMetadataHost(host)) return { ok: false, reason: 'metadata-address-blocked', kind: 'metadata' }
|
|
126
|
+
const family = net.isIP(host)
|
|
127
|
+
if (family === 0) {
|
|
128
|
+
if (host === 'localhost') return { ok: true, kind: 'loopback' }
|
|
129
|
+
return { ok: true, kind: 'hostname' }
|
|
130
|
+
}
|
|
131
|
+
if (isMetadataAddress(host)) return { ok: false, reason: 'metadata-address-blocked', kind: 'metadata' }
|
|
132
|
+
if (family === 4 && inV4Range(ipv4Parts(host) ?? [], ipv4ToInt([127, 0, 0, 0]), 8)) return { ok: true, kind: 'loopback' }
|
|
133
|
+
if (family === 6) {
|
|
134
|
+
const groups = ipv6Groups(host)
|
|
135
|
+
if (groups && groups.every((group, index) => (index < 7 ? group === 0 : group === 1))) return { ok: true, kind: 'loopback' }
|
|
136
|
+
}
|
|
137
|
+
if ((family === 4 && isPrivateIpv4(host)) || (family === 6 && isPrivateIpv6(host))) {
|
|
138
|
+
if (allowed.has(host)) return { ok: true, kind: 'private-allowlisted' }
|
|
139
|
+
return { ok: false, reason: 'private-address-blocked', kind: 'private' }
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, kind: family === 4 ? 'public-ipv4' : 'public-ipv6' }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 请求前 DNS 预检;解析失败视为 NXDOMAIN(交给 fetch 失败,不视为 SSRF)。 */
|
|
145
|
+
export async function assertEndpointSafe(hostname, { allowHosts = [], resolve = dns.lookup, timeoutMs = 1500 } = {}) {
|
|
146
|
+
const sync = classifyEndpointHost(hostname, { allowHosts })
|
|
147
|
+
if (!sync.ok) return sync
|
|
148
|
+
if (sync.kind !== 'hostname') return sync
|
|
149
|
+
const host = normalizeHost(hostname)
|
|
150
|
+
const allowed = new Set((Array.isArray(allowHosts) ? allowHosts : []).map(normalizeHost))
|
|
151
|
+
// 明确的 metadata 主机名已在 sync 拦截;其余做一次解析。
|
|
152
|
+
let addresses = []
|
|
153
|
+
try {
|
|
154
|
+
const result = await Promise.race([
|
|
155
|
+
resolve(host, { all: true, verbatim: true }),
|
|
156
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('dns-timeout')), timeoutMs).unref?.()),
|
|
157
|
+
])
|
|
158
|
+
addresses = Array.isArray(result) ? result : (result?.address ? [result] : [])
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return { ok: true, kind: 'hostname-unresolved', detail: String(error?.message ?? error) }
|
|
161
|
+
}
|
|
162
|
+
for (const entry of addresses) {
|
|
163
|
+
const address = normalizeHost(entry?.address ?? entry)
|
|
164
|
+
if (isMetadataAddress(address)) return { ok: false, reason: 'metadata-address-blocked', kind: 'metadata', address }
|
|
165
|
+
if (isPrivateAddress(address) && !allowed.has(host)) {
|
|
166
|
+
return { ok: false, reason: 'private-address-blocked', kind: 'private', address }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { ok: true, kind: 'hostname-public', addresses: addresses.map((entry) => normalizeHost(entry?.address ?? entry)) }
|
|
170
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// dsh-Fang v3.1.0 — MirrorStatusStore(插件层外部 store,框架无关)。
|
|
3
|
+
//
|
|
4
|
+
// 供 DSH client-half(React)用 useSyncExternalStore 订阅:
|
|
5
|
+
// const snap = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
|
|
6
|
+
//
|
|
7
|
+
// 语义:
|
|
8
|
+
// * 订阅者 0 → 不轮询;首个订阅者立即 refresh;
|
|
9
|
+
// * 轮询间隔 = activeConsumers > 0 且未空闲降级 ? activeIntervalMs : idleIntervalMs;
|
|
10
|
+
// * acquireActive() 引用计数 + 每次调用重置 idleTimer(默认 30s 无交互 → 降回 idle);
|
|
11
|
+
// * pokeActive() 只报告“用户还活着”,不改变引用计数(refcount=0 时 no-op);
|
|
12
|
+
// * 降级后 pokeActive() 立即恢复高频档(不立即 refresh,节奏仍由 interval 决定);
|
|
13
|
+
// * refresh 结果 304 时只更新 lastUpdatedAt,不替换 status 引用;
|
|
14
|
+
// * dispose() 清理所有定时器并 abort in-flight。
|
|
15
|
+
//
|
|
16
|
+
// 测试可注入 timers/now/fetcher,避免真实等待。
|
|
17
|
+
|
|
18
|
+
export const INITIAL_MIRROR_SNAPSHOT = Object.freeze({
|
|
19
|
+
status: null, loading: false, error: null, lastUpdatedAt: null,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export class MirrorStatusStore {
|
|
23
|
+
/**
|
|
24
|
+
* @param {{fetcher: (options: {force?: boolean, signal?: AbortSignal}) => Promise<{status: object|null, notModified: boolean}>,
|
|
25
|
+
* idleIntervalMs?: number, activeIntervalMs?: number, activeIdleTimeoutMs?: number,
|
|
26
|
+
* logger?: {warn?: Function},
|
|
27
|
+
* timers?: {setTimeout: Function, clearTimeout: Function, setInterval: Function, clearInterval: Function},
|
|
28
|
+
* now?: () => number}} options
|
|
29
|
+
*/
|
|
30
|
+
constructor(options) {
|
|
31
|
+
if (options === null || typeof options !== 'object' || typeof options.fetcher !== 'function') {
|
|
32
|
+
throw new Error('MirrorStatusStore: fetcher required')
|
|
33
|
+
}
|
|
34
|
+
this.fetcher = options.fetcher
|
|
35
|
+
this.idleIntervalMs = Math.max(1, Number(options.idleIntervalMs) || 60_000)
|
|
36
|
+
this.activeIntervalMs = Math.max(1, Number(options.activeIntervalMs) || 5_000)
|
|
37
|
+
this.activeIdleTimeoutMs = Math.max(1, Number(options.activeIdleTimeoutMs) || 30_000)
|
|
38
|
+
this.logger = options.logger ?? {}
|
|
39
|
+
this.timers = options.timers ?? { setTimeout, clearTimeout, setInterval, clearInterval }
|
|
40
|
+
this.now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
41
|
+
this.snapshot = INITIAL_MIRROR_SNAPSHOT
|
|
42
|
+
this.subscribers = new Set()
|
|
43
|
+
this.idleSinks = new Set()
|
|
44
|
+
this.activeConsumers = 0
|
|
45
|
+
this.activeAcquiredAt = null
|
|
46
|
+
this.lastPokeAt = null
|
|
47
|
+
this.idleTimedOut = false
|
|
48
|
+
this.idleTimer = null
|
|
49
|
+
this.timer = null
|
|
50
|
+
this.inFlight = false
|
|
51
|
+
this.stopped = false
|
|
52
|
+
this.controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
53
|
+
this.getSnapshot = () => this.snapshot
|
|
54
|
+
this.subscribe = (callback) => this.addSubscriber(callback)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
addSubscriber(callback) {
|
|
58
|
+
this.subscribers.add(callback)
|
|
59
|
+
if (this.subscribers.size === 1) this.startPolling()
|
|
60
|
+
return () => {
|
|
61
|
+
this.subscribers.delete(callback)
|
|
62
|
+
if (this.subscribers.size === 0) this.stopPolling()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
startPolling() {
|
|
67
|
+
void this.refresh()
|
|
68
|
+
this.rescheduleTimer()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
stopPolling() {
|
|
72
|
+
this.clearTimer()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 注册高频消费者;返回幂等 release。 */
|
|
76
|
+
acquireActive() {
|
|
77
|
+
this.activeConsumers += 1
|
|
78
|
+
if (this.activeConsumers === 1) {
|
|
79
|
+
this.activeAcquiredAt = this.now()
|
|
80
|
+
this.lastPokeAt = null
|
|
81
|
+
}
|
|
82
|
+
this.idleTimedOut = false
|
|
83
|
+
this.resetIdleTimer()
|
|
84
|
+
this.rescheduleTimer()
|
|
85
|
+
let released = false
|
|
86
|
+
return () => {
|
|
87
|
+
if (released) return
|
|
88
|
+
released = true
|
|
89
|
+
this.activeConsumers = Math.max(0, this.activeConsumers - 1)
|
|
90
|
+
if (this.activeConsumers === 0) {
|
|
91
|
+
this.idleTimedOut = false
|
|
92
|
+
this.activeAcquiredAt = null
|
|
93
|
+
this.lastPokeAt = null
|
|
94
|
+
this.clearIdleTimer()
|
|
95
|
+
}
|
|
96
|
+
this.rescheduleTimer()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 报告用户交互;refcount=0 或已 dispose 时 no-op。 */
|
|
101
|
+
pokeActive() {
|
|
102
|
+
if (this.stopped || this.activeConsumers === 0) return
|
|
103
|
+
this.lastPokeAt = this.now()
|
|
104
|
+
if (!this.idleTimedOut) { this.resetIdleTimer(); return }
|
|
105
|
+
this.idleTimedOut = false
|
|
106
|
+
this.resetIdleTimer()
|
|
107
|
+
this.rescheduleTimer()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 是否处于空闲降级态(供重算轮询等消费者降频)。 */
|
|
111
|
+
isIdle() {
|
|
112
|
+
return this.idleTimedOut
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 订阅空闲降级事件(用于 Auditor 统计“打开后多久走开”)。
|
|
117
|
+
* @param {(event: {at:number, timeoutMs:number, acquiredAt:number, lastPokeAt:number|null,
|
|
118
|
+
* idleAfterMs:number, totalActiveMs:number, activeConsumers:number}) => void} sink
|
|
119
|
+
* @returns {() => void} unsubscribe
|
|
120
|
+
*/
|
|
121
|
+
onIdleDowngraded(sink) {
|
|
122
|
+
if (typeof sink !== 'function') throw new TypeError('onIdleDowngraded: sink must be a function')
|
|
123
|
+
this.idleSinks.add(sink)
|
|
124
|
+
return () => { this.idleSinks.delete(sink) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 当前有效轮询间隔(诊断/测试用)。 */
|
|
128
|
+
currentIntervalMs() {
|
|
129
|
+
return this.activeConsumers > 0 && !this.idleTimedOut ? this.activeIntervalMs : this.idleIntervalMs
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 当前交互超时(毫秒)。 */
|
|
133
|
+
getActiveIdleTimeout() {
|
|
134
|
+
return this.activeIdleTimeoutMs
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 运行时调整“无交互降级”超时(设置面板 15/30/60s)。
|
|
139
|
+
* - 无活跃消费者:只记录,下次 acquireActive 生效;
|
|
140
|
+
* - 已降级:视为用户回来操作,恢复高频并重建 idleTimer;
|
|
141
|
+
* - 活跃未降级:只重建 idleTimer(不重建轮询 timer,避免推迟下一次轮询)。
|
|
142
|
+
* @param {number} ms
|
|
143
|
+
*/
|
|
144
|
+
setActiveIdleTimeout(ms) {
|
|
145
|
+
const next = Number(ms)
|
|
146
|
+
if (!Number.isFinite(next) || next < 1) throw new RangeError('invalid activeIdleTimeoutMs')
|
|
147
|
+
if (next === this.activeIdleTimeoutMs) return
|
|
148
|
+
this.activeIdleTimeoutMs = next
|
|
149
|
+
if (this.stopped || this.activeConsumers === 0) return
|
|
150
|
+
if (this.idleTimedOut) {
|
|
151
|
+
this.idleTimedOut = false
|
|
152
|
+
this.rescheduleTimer()
|
|
153
|
+
}
|
|
154
|
+
this.resetIdleTimer()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async refresh(options = {}) {
|
|
158
|
+
if (this.stopped || this.inFlight) return
|
|
159
|
+
if (this.controller !== null && this.controller.signal.aborted) return
|
|
160
|
+
this.inFlight = true
|
|
161
|
+
this.update({ loading: true })
|
|
162
|
+
try {
|
|
163
|
+
const result = await this.fetcher({
|
|
164
|
+
force: options.force === true,
|
|
165
|
+
signal: this.controller === null ? undefined : this.controller.signal,
|
|
166
|
+
})
|
|
167
|
+
if (this.stopped || (this.controller !== null && this.controller.signal.aborted)) return
|
|
168
|
+
if (result.notModified === true) {
|
|
169
|
+
this.update({ loading: false, error: null, lastUpdatedAt: this.now() })
|
|
170
|
+
} else if (result.status !== null && result.status !== undefined) {
|
|
171
|
+
this.update({ status: result.status, loading: false, error: null, lastUpdatedAt: this.now() })
|
|
172
|
+
}
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (this.stopped || (this.controller !== null && this.controller.signal.aborted)) return
|
|
175
|
+
this.logger.warn?.('[mirror-status] refresh failed', {
|
|
176
|
+
error: String(error instanceof Error ? error.message : error),
|
|
177
|
+
})
|
|
178
|
+
this.update({ loading: false, error: String(error instanceof Error ? error.message : error) })
|
|
179
|
+
} finally {
|
|
180
|
+
this.inFlight = false
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
dispose() {
|
|
185
|
+
this.stopped = true
|
|
186
|
+
this.clearTimer()
|
|
187
|
+
this.clearIdleTimer()
|
|
188
|
+
if (this.controller !== null) this.controller.abort()
|
|
189
|
+
this.subscribers.clear()
|
|
190
|
+
this.idleSinks.clear()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
resetIdleTimer() {
|
|
196
|
+
this.clearIdleTimer()
|
|
197
|
+
if (this.stopped || this.activeConsumers === 0) return
|
|
198
|
+
this.idleTimer = this.timers.setTimeout(() => { this.handleIdleTimeout() }, this.activeIdleTimeoutMs)
|
|
199
|
+
this.idleTimer?.unref?.()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 降级发生:记录事件(真实走开时长 = now - lastPokeAt/acquiredAt)并通知订阅者。 */
|
|
203
|
+
handleIdleTimeout() {
|
|
204
|
+
this.idleTimer = null
|
|
205
|
+
if (this.stopped || this.activeConsumers === 0) return
|
|
206
|
+
const at = this.now()
|
|
207
|
+
const acquiredAt = this.activeAcquiredAt ?? at
|
|
208
|
+
const reference = this.lastPokeAt ?? acquiredAt
|
|
209
|
+
const event = {
|
|
210
|
+
at,
|
|
211
|
+
timeoutMs: this.activeIdleTimeoutMs,
|
|
212
|
+
acquiredAt,
|
|
213
|
+
lastPokeAt: this.lastPokeAt,
|
|
214
|
+
idleAfterMs: at - reference,
|
|
215
|
+
totalActiveMs: at - acquiredAt,
|
|
216
|
+
activeConsumers: this.activeConsumers,
|
|
217
|
+
}
|
|
218
|
+
this.idleTimedOut = true
|
|
219
|
+
this.rescheduleTimer()
|
|
220
|
+
this.logger.warn?.('[mirror-status] active consumer idle, downgrading to idle interval', {
|
|
221
|
+
idleIntervalMs: this.idleIntervalMs, idleAfterMs: event.idleAfterMs,
|
|
222
|
+
})
|
|
223
|
+
for (const sink of [...this.idleSinks]) {
|
|
224
|
+
try { sink(event) } catch { /* 订阅者异常隔离 */ }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
clearIdleTimer() {
|
|
229
|
+
if (this.idleTimer !== null) {
|
|
230
|
+
this.timers.clearTimeout(this.idleTimer)
|
|
231
|
+
this.idleTimer = null
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
rescheduleTimer() {
|
|
236
|
+
if (this.stopped || this.subscribers.size === 0) return
|
|
237
|
+
this.clearTimer()
|
|
238
|
+
this.timer = this.timers.setInterval(() => { void this.refresh() }, this.currentIntervalMs())
|
|
239
|
+
this.timer?.unref?.()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
clearTimer() {
|
|
243
|
+
if (this.timer !== null) {
|
|
244
|
+
this.timers.clearInterval(this.timer)
|
|
245
|
+
this.timer = null
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
update(patch) {
|
|
250
|
+
this.snapshot = { ...this.snapshot, ...patch }
|
|
251
|
+
for (const callback of [...this.subscribers]) {
|
|
252
|
+
try { callback() } catch { /* 订阅者异常隔离 */ }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export const _internals = Object.freeze({ INITIAL_MIRROR_SNAPSHOT })
|