dsh-kylin-media 0.2.0 → 0.2.1
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/README.md +2 -0
- package/client.js +10 -2
- package/entry.js +1 -1
- package/lib/gateway.js +51 -30
- package/lib/routes.js +1 -1
- package/package.json +1 -1
- package/scripts/client-test.mjs +9 -1
- package/scripts/selftest.mjs +39 -0
package/README.md
CHANGED
|
@@ -66,6 +66,8 @@ npm publish # prepack 会自动跑 smoke
|
|
|
66
66
|
> 1. `127.0.0.1` / `localhost` 只有**网关和插件跑在同一台机器**时才通。网关在远端(例如 Windows 主机)而插件在 Mac 上时,本机地址必然被拒绝。
|
|
67
67
|
> 2. 局域网地址要求两者**在同一网段**,且网关监听 `HOST=0.0.0.0`(默认)、系统防火墙放行 8288(仓库里有 `add-firewall-rule.bat`)。可先用 `curl http://<主机IP>:8288/api/health` 验证。
|
|
68
68
|
> 3. 上面两条都不满足时只能用 frp / 公网地址。把三条地址都加进「连接线路」,点「全部测速」即可看出哪条通、哪条最快;失败原因直接写在行内(拒绝连接 / 不可达 / 域名解析失败 / 协议写反)。
|
|
69
|
+
>
|
|
70
|
+
> 局域网里偶发 `EHOSTUNREACH`(刚唤醒、Wi-Fi 切换、ARP 未就绪)不用管:所有 GET 探测都会自动重试最多 2 次;仍失败时先在本机 `curl http://<主机IP>:8288/api/health` 复核。
|
|
69
71
|
|
|
70
72
|
## 工具
|
|
71
73
|
|
package/client.js
CHANGED
|
@@ -55,6 +55,7 @@ window.__ModuleLoader__.load({
|
|
|
55
55
|
lineExists: '这个地址已经在列表里了',
|
|
56
56
|
lineKeepOne: '至少要保留一条线路',
|
|
57
57
|
noLineReachable: '所有线路都不通:检查网关是否运行、地址/端口是否正确、防火墙是否放行',
|
|
58
|
+
localNetworkHint: '(局域网目标全部 EHOSTUNREACH:多为 macOS「本地网络」权限拦截——去 系统设置 → 隐私与安全性 → 本地网络 放行本 App 后重试)',
|
|
58
59
|
fastestLine: '最快线路:',
|
|
59
60
|
saved: '已保存',
|
|
60
61
|
failed: '操作失败:',
|
|
@@ -150,6 +151,7 @@ window.__ModuleLoader__.load({
|
|
|
150
151
|
lineExists: 'That address is already in the list',
|
|
151
152
|
lineKeepOne: 'Keep at least one line',
|
|
152
153
|
noLineReachable: 'No line is reachable: check the gateway process, address/port and firewall',
|
|
154
|
+
localNetworkHint: ' (every LAN target returns EHOSTUNREACH: usually macOS Local Network privacy - allow this app in System Settings > Privacy & Security > Local Network, then retry)',
|
|
153
155
|
fastestLine: 'Fastest line: ',
|
|
154
156
|
saved: 'Saved',
|
|
155
157
|
failed: 'Operation failed: ',
|
|
@@ -484,8 +486,14 @@ window.__ModuleLoader__.load({
|
|
|
484
486
|
if (result.reachable && (best === null || result.latencyMs < best.latencyMs)) best = result;
|
|
485
487
|
}
|
|
486
488
|
setLineResults(next);
|
|
487
|
-
if (best === null)
|
|
488
|
-
|
|
489
|
+
if (best === null) {
|
|
490
|
+
var blocked = false;
|
|
491
|
+
for (var check = 0; check < payload.value.results.length; check += 1) {
|
|
492
|
+
var failed = payload.value.results[check];
|
|
493
|
+
if (failed.reachable === false && failed.error && /EHOSTUNREACH/.test(String(failed.error.message))) blocked = true;
|
|
494
|
+
}
|
|
495
|
+
setNotice({ kind: 'error', text: t('noLineReachable') + (blocked ? t('localNetworkHint') : '') });
|
|
496
|
+
} else setNotice({ kind: 'ok', text: t('fastestLine') + best.name + ' · ' + String(best.latencyMs) + 'ms' });
|
|
489
497
|
}).catch(function (error) {
|
|
490
498
|
setNotice({ kind: 'error', text: t('failed') + (error && error.message ? error.message : String(error)) });
|
|
491
499
|
}).then(function () {
|
package/entry.js
CHANGED
|
@@ -17,7 +17,7 @@ import { createTools } from './lib/tools.js'
|
|
|
17
17
|
|
|
18
18
|
export const name = 'dsh-kylin-media'
|
|
19
19
|
export const inject = ['tools', 'webServer']
|
|
20
|
-
export const VERSION = '0.2.
|
|
20
|
+
export const VERSION = '0.2.1'
|
|
21
21
|
|
|
22
22
|
const ROOT = dirname(fileURLToPath(import.meta.url))
|
|
23
23
|
const SKILLS_DIR = join(ROOT, 'skills')
|
package/lib/gateway.js
CHANGED
|
@@ -112,7 +112,10 @@ function reachabilityHint(base, detail) {
|
|
|
112
112
|
return '。目标拒绝连接:确认网关进程在运行、端口正确,并已放行防火墙'
|
|
113
113
|
}
|
|
114
114
|
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(detail)) return '。域名解析失败:检查地址拼写或 DNS'
|
|
115
|
-
if (/EHOSTUNREACH|ENETUNREACH
|
|
115
|
+
if (/EHOSTUNREACH|ENETUNREACH/i.test(detail)) {
|
|
116
|
+
return '。链路层没通:目标在同一局域网时,先在 macOS「系统设置 → 隐私与安全性 → 本地网络」放行本 App(被拦时就是 EHOSTUNREACH);其次看网关主机是否休眠、刚切过 Wi-Fi 或 ARP 未就绪,隔几秒重试。可在终端 curl ' + base + '/api/health 复核'
|
|
117
|
+
}
|
|
118
|
+
if (/ETIMEDOUT|timed out|timeout/i.test(detail)) return '。SYN 没响应:多半是防火墙丢包(Windows 入站规则要放行该端口),或两台机器不在同一网段'
|
|
116
119
|
return ''
|
|
117
120
|
}
|
|
118
121
|
|
|
@@ -143,41 +146,59 @@ export function createGateway(settings) {
|
|
|
143
146
|
return headers
|
|
144
147
|
}
|
|
145
148
|
|
|
149
|
+
/** 刚唤醒 / Wi-Fi 切换 / ARP 未就绪时的瞬时失败,重试就能过去。 */
|
|
150
|
+
function isTransient(detail) {
|
|
151
|
+
return /EHOSTUNREACH|ENETUNREACH|ECONNRESET|EPIPE|EAI_AGAIN|socket hang up/i.test(detail)
|
|
152
|
+
}
|
|
153
|
+
|
|
146
154
|
async function request(path, options) {
|
|
147
155
|
const opts = options === undefined ? {} : options
|
|
156
|
+
const method = opts.method || 'GET'
|
|
148
157
|
const timeoutMs = opts.timeoutMs === undefined ? defaultTimeout : opts.timeoutMs
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
158
|
+
// 只重试幂等的 GET/HEAD;POST 可能已经在网关侧建了任务,绝不重放。
|
|
159
|
+
const attempts = method === 'GET' || method === 'HEAD' ? 3 : 1
|
|
160
|
+
let lastError = null
|
|
161
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
162
|
+
const combined = combineSignal(opts.signal, timeoutMs)
|
|
163
|
+
try {
|
|
164
|
+
const response = await fetch(base + path, {
|
|
165
|
+
method,
|
|
166
|
+
headers: opts.headers || authHeaders({}),
|
|
167
|
+
body: opts.body,
|
|
168
|
+
signal: combined.signal
|
|
169
|
+
})
|
|
170
|
+
const text = await response.text()
|
|
171
|
+
let payload = null
|
|
172
|
+
if (text !== '') {
|
|
173
|
+
try {
|
|
174
|
+
payload = JSON.parse(text)
|
|
175
|
+
} catch {
|
|
176
|
+
payload = null
|
|
177
|
+
}
|
|
164
178
|
}
|
|
179
|
+
if (!response.ok || (isRecord(payload) && payload['ok'] === false)) {
|
|
180
|
+
throw failForStatus(response.status, payload, text)
|
|
181
|
+
}
|
|
182
|
+
return payload
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof GatewayError) throw error
|
|
185
|
+
if (opts.signal !== undefined && opts.signal !== null && opts.signal.aborted) throw error
|
|
186
|
+
if (combined.timedOut()) {
|
|
187
|
+
throw new GatewayError('timeout', '请求网关超时(' + String(timeoutMs) + 'ms):' + base + reachabilityHint(base, 'ETIMEDOUT'), 0, null)
|
|
188
|
+
}
|
|
189
|
+
const detail = describeCause(error)
|
|
190
|
+
const retryNote = attempt < attempts && isTransient(detail) ? '' : (attempts > 1 && isTransient(detail) ? '(已自动重试 ' + String(attempts - 1) + ' 次)' : '')
|
|
191
|
+
lastError = new GatewayError('gateway-unreachable', '无法连接网关 ' + base + ':' + detail + reachabilityHint(base, detail) + retryNote, 0, null)
|
|
192
|
+
if (attempt < attempts && isTransient(detail)) {
|
|
193
|
+
await new Promise(function (resolve) { setTimeout(resolve, 250 * attempt) })
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
throw lastError
|
|
197
|
+
} finally {
|
|
198
|
+
combined.close()
|
|
165
199
|
}
|
|
166
|
-
if (!response.ok || (isRecord(payload) && payload['ok'] === false)) {
|
|
167
|
-
throw failForStatus(response.status, payload, text)
|
|
168
|
-
}
|
|
169
|
-
return payload
|
|
170
|
-
} catch (error) {
|
|
171
|
-
if (error instanceof GatewayError) throw error
|
|
172
|
-
if (opts.signal !== undefined && opts.signal !== null && opts.signal.aborted) throw error
|
|
173
|
-
if (combined.timedOut()) {
|
|
174
|
-
throw new GatewayError('timeout', '请求网关超时(' + String(timeoutMs) + 'ms):' + base + reachabilityHint(base, 'ETIMEDOUT'), 0, null)
|
|
175
|
-
}
|
|
176
|
-
const detail = describeCause(error)
|
|
177
|
-
throw new GatewayError('gateway-unreachable', '无法连接网关 ' + base + ':' + detail + reachabilityHint(base, detail), 0, null)
|
|
178
|
-
} finally {
|
|
179
|
-
combined.close()
|
|
180
200
|
}
|
|
201
|
+
throw lastError
|
|
181
202
|
}
|
|
182
203
|
|
|
183
204
|
async function health() {
|
package/lib/routes.js
CHANGED
|
@@ -171,7 +171,7 @@ export async function handleRequest(store, request, body) {
|
|
|
171
171
|
if (!pathname.startsWith(API_PREFIX)) return undefined
|
|
172
172
|
const action = pathname.slice(API_PREFIX.length).replace(/^\/+/, '')
|
|
173
173
|
if (method === 'GET' && action === 'state') {
|
|
174
|
-
return { status: 200, payload: { ok: true, value: { ...store.publicState(), name: PLUGIN_NAME, version: '0.2.
|
|
174
|
+
return { status: 200, payload: { ok: true, value: { ...store.publicState(), name: PLUGIN_NAME, version: '0.2.1' } } }
|
|
175
175
|
}
|
|
176
176
|
if (method !== 'POST') {
|
|
177
177
|
return { status: 405, payload: errorPayload('method-not-allowed', '写操作必须使用 POST') }
|
package/package.json
CHANGED
package/scripts/client-test.mjs
CHANGED
|
@@ -166,7 +166,7 @@ function respond(action, options) {
|
|
|
166
166
|
latencyMs: reachable ? 18 : 3,
|
|
167
167
|
gateway: reachable ? { version: '0.3.0', authMode: 'always' } : null,
|
|
168
168
|
comfy: reachable ? { reachable: true, upstreams: 2 } : null,
|
|
169
|
-
error: reachable ? null : { code: '
|
|
169
|
+
error: reachable ? null : { code: 'gateway-unreachable', message: '无法连接网关:EHOSTUNREACH connect EHOSTUNREACH' }
|
|
170
170
|
}
|
|
171
171
|
}) } }
|
|
172
172
|
}
|
|
@@ -324,6 +324,14 @@ const statuses = findAll(render(), function (node) {
|
|
|
324
324
|
return typeof node.props.className === 'string' && node.props.className.indexOf('km-line-status') >= 0
|
|
325
325
|
}).map(textOf)
|
|
326
326
|
check(statuses.indexOf('18ms') >= 0 && statuses.indexOf('lineFailed') >= 0, 'test all renders per-line latency and failure')
|
|
327
|
+
// 全部线路都 EHOSTUNREACH 时给出 macOS 本地网络权限提示
|
|
328
|
+
const savedLineList = STATE.endpoints
|
|
329
|
+
STATE.endpoints = [{ id: 'local', name: '127.0.0.1:8288', baseUrl: 'http://127.0.0.1:8288' }]
|
|
330
|
+
const onlyLocalButton = findAll(render(), function (node) { return node.type === 'button' && textOf(node) === 'lineTestAll' })[0]
|
|
331
|
+
onlyLocalButton.props.onClick()
|
|
332
|
+
await settle()
|
|
333
|
+
check(textOf(render()).includes('localNetworkHint'), 'all-EHOSTUNREACH lines surface the macOS local-network hint')
|
|
334
|
+
STATE.endpoints = savedLineList
|
|
327
335
|
|
|
328
336
|
const urlInput = findAll(render(), function (node) { return node.props.id === 'km-base-url' })[0]
|
|
329
337
|
urlInput.props.onChange({ target: { value: 'http://192.168.1.5:8288' } })
|
package/scripts/selftest.mjs
CHANGED
|
@@ -130,6 +130,45 @@ await new Promise(function (resolve) { server.listen(0, '127.0.0.1', resolve) })
|
|
|
130
130
|
const port = server.address().port
|
|
131
131
|
const baseUrl = 'http://127.0.0.1:' + String(port)
|
|
132
132
|
|
|
133
|
+
// GET 自动重试:瞬时 EHOSTUNREACH(刚唤醒 / ARP 未就绪)重试后应成功;一直失败时给出已重试次数
|
|
134
|
+
const { createGateway } = await import(new URL('../lib/gateway.js', import.meta.url))
|
|
135
|
+
function hostUnreachable() {
|
|
136
|
+
const error = new TypeError('fetch failed')
|
|
137
|
+
const cause = new Error('connect EHOSTUNREACH 192.168.1.71:8288')
|
|
138
|
+
cause.code = 'EHOSTUNREACH'
|
|
139
|
+
error.cause = cause
|
|
140
|
+
return error
|
|
141
|
+
}
|
|
142
|
+
const originalFetch = globalThis.fetch
|
|
143
|
+
let flakyCalls = 0
|
|
144
|
+
globalThis.fetch = async function (url, init) {
|
|
145
|
+
flakyCalls += 1
|
|
146
|
+
if (flakyCalls === 1) throw hostUnreachable()
|
|
147
|
+
return originalFetch(url, init)
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const flakyHealth = await createGateway({ baseUrl, apiKey: KEY, requestTimeoutMs: 5000 }).health()
|
|
151
|
+
check(flakyHealth.version === '0.3.0', 'a transient EHOSTUNREACH is retried and then succeeds')
|
|
152
|
+
} finally {
|
|
153
|
+
globalThis.fetch = originalFetch
|
|
154
|
+
}
|
|
155
|
+
check(flakyCalls === 2, 'the transient GET was retried exactly once')
|
|
156
|
+
|
|
157
|
+
let downCalls = 0
|
|
158
|
+
globalThis.fetch = async function () {
|
|
159
|
+
downCalls += 1
|
|
160
|
+
throw hostUnreachable()
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
await createGateway({ baseUrl, apiKey: KEY, requestTimeoutMs: 5000 }).health()
|
|
164
|
+
check(false, 'a permanently unreachable gateway should fail')
|
|
165
|
+
} catch (error) {
|
|
166
|
+
check(error.message.includes('已自动重试 2 次'), 'exhausted retries are reported in the message')
|
|
167
|
+
} finally {
|
|
168
|
+
globalThis.fetch = originalFetch
|
|
169
|
+
}
|
|
170
|
+
check(downCalls === 3, 'a GET is attempted three times before failing')
|
|
171
|
+
|
|
133
172
|
const store = new MediaStore(HOME)
|
|
134
173
|
let result = store.update({ baseUrl, waitSeconds: 10, pollIntervalMs: 500, requestTimeoutMs: 5000, autoDownload: true })
|
|
135
174
|
check(result.ok, 'store.update accepts valid settings')
|