@xcanwin/manyoyo 8.6.4 → 8.6.6

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/bin/manyoyo.js CHANGED
@@ -1395,6 +1395,7 @@ https://github.com/xcanwin/manyoyo
1395
1395
  [
1396
1396
  ['down', '停止当前模式,回到默认模式'],
1397
1397
  ['status', '查看当前模式并真实探测浏览器是否可用(不可用时退出码非 0)'],
1398
+ ['probe', '真实探测当前模式(不切换模式、失败也不回退;默认模式会起一次性容器;失败退出码非 0)'],
1398
1399
  ['logs', '查看当前模式的浏览器服务日志'],
1399
1400
  ['mcp-add', '输出在容器内注册 Playwright MCP(stdio,随当前模式自动切换)的命令']
1400
1401
  ].forEach(([action, description]) => {
@@ -16,11 +16,16 @@ function sha256File(filePath) {
16
16
  });
17
17
  }
18
18
 
19
+ const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
20
+
21
+ class RetryableError extends Error {}
22
+
19
23
  /**
20
24
  * 下载并校验 SHA256。缓存命中(文件存在且哈希一致)直接复用;
21
25
  * 哈希不符一律删除并报错,绝不保留不可信文件。
26
+ * 5xx / 429 / 网络错误(含下载中途断开)按 1s、2s、4s 退避,最多重试 retries 次;4xx 与哈希不符不重试。
22
27
  */
23
- async function downloadVerified({ url, sha256, dest, fetchImpl = fetch, log = () => {} }) {
28
+ async function downloadVerified({ url, sha256, dest, fetchImpl = fetch, log = () => {}, retries = 3, retryDelayMs = 1000 }) {
24
29
  if (!/^[0-9a-f]{64}$/.test(sha256 || '')) {
25
30
  throw new Error(`缺少有效的 SHA256: ${url}`);
26
31
  }
@@ -29,13 +34,20 @@ async function downloadVerified({ url, sha256, dest, fetchImpl = fetch, log = ()
29
34
  log(`缓存命中: ${path.basename(dest)}`);
30
35
  return dest;
31
36
  }
32
- log(`下载: ${url}`);
33
- const response = await fetchImpl(url, { redirect: 'follow' });
34
- if (!response.ok || !response.body) {
35
- throw new Error(`下载失败 (${response.status}): ${url}`);
36
- }
37
37
  const partial = `${dest}.partial`;
38
- await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(partial));
38
+ for (let attempt = 0; ; attempt += 1) {
39
+ try {
40
+ await fetchOnce({ url, partial, fetchImpl, log });
41
+ break;
42
+ } catch (error) {
43
+ fs.rmSync(partial, { force: true });
44
+ const retryable = error instanceof RetryableError || !(error && error.status);
45
+ if (!retryable || attempt >= retries) throw error instanceof RetryableError ? error.cause : error;
46
+ const wait = retryDelayMs * (2 ** attempt);
47
+ log(`下载失败,${wait / 1000} 秒后重试(${attempt + 1}/${retries}): ${error.message}`);
48
+ await new Promise(resolve => setTimeout(resolve, wait));
49
+ }
50
+ }
39
51
  const actual = await sha256File(partial);
40
52
  if (actual !== sha256) {
41
53
  fs.rmSync(partial, { force: true });
@@ -45,4 +57,15 @@ async function downloadVerified({ url, sha256, dest, fetchImpl = fetch, log = ()
45
57
  return dest;
46
58
  }
47
59
 
60
+ async function fetchOnce({ url, partial, fetchImpl, log }) {
61
+ log(`下载: ${url}`);
62
+ const response = await fetchImpl(url, { redirect: 'follow' });
63
+ if (!response.ok || !response.body) {
64
+ const error = Object.assign(new Error(`下载失败 (${response.status}): ${url}`), { status: response.status });
65
+ if (RETRYABLE_STATUS.has(response.status)) throw Object.assign(new RetryableError(error.message), { cause: error });
66
+ throw error;
67
+ }
68
+ await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(partial));
69
+ }
70
+
48
71
  module.exports = { sha256File, downloadVerified };
@@ -35,6 +35,15 @@ const SERVER_START_TIMEOUT_MS = 30000;
35
35
  const PROBE_TIMEOUT_MS = 5000;
36
36
  // chrome 首次连接要等用户在 Chrome 弹出的“要允许远程调试吗?”里点允许
37
37
  const CHROME_APPROVAL_TIMEOUT_MS = 60000;
38
+ // 默认模式的探测:一次性容器里用 playwright-cli 打开 data: 页面,再读回标题(读到 manyoyo-ok 才算通过)
39
+ const CONTAINER_PROBE_TIMEOUT_MS = 180000;
40
+ const CONTAINER_PROBE_SCRIPT = [
41
+ 'playwright-cli -s=manyoyo-probe open "data:text/html,<title>manyoyo-ok</title>" >/dev/null 2>&1',
42
+ 'out=$(playwright-cli -s=manyoyo-probe eval document.title 2>&1)',
43
+ 'playwright-cli -s=manyoyo-probe close >/dev/null 2>&1',
44
+ 'echo "$out"',
45
+ 'case "$out" in *manyoyo-ok*) exit 0;; *) exit 1;; esac'
46
+ ].join('; ');
38
47
 
39
48
  function asObject(value) {
40
49
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
@@ -399,10 +408,11 @@ class PlaywrightPlugin {
399
408
  return this.runtime;
400
409
  }
401
410
 
402
- runtimeCall(args, { capture = true } = {}) {
411
+ runtimeCall(args, { capture = true, timeout } = {}) {
403
412
  const runtime = this.resolveRuntime();
404
413
  const result = spawnSync(runtime.command, args, {
405
414
  encoding: 'utf8',
415
+ ...(timeout ? { timeout } : {}),
406
416
  env: { ...process.env, ...runtime.env },
407
417
  stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
408
418
  });
@@ -899,6 +909,46 @@ class PlaywrightPlugin {
899
909
  return 1;
900
910
  }
901
911
 
912
+ // 真实探测当前模式(不切换模式、失败也不回退;发布流水线用它:status 在失败时会把模式回退成默认;默认模式会按需重建 current/ 里的配置文件)。
913
+ // 宿主机上的模式连上浏览器服务开页面;默认模式(容器内浏览器)起一个一次性容器,真的打开页面并读回标题
914
+ async probe() {
915
+ const state = this.readState();
916
+ if (state.mode !== 'default') {
917
+ const alive = await this.modeAlive(state);
918
+ const result = alive ? await this.probeState(state) : { ok: false, error: state.mode === 'vnc' ? 'vnc 容器没有在运行' : '浏览器服务进程已不在' };
919
+ if (result.ok) {
920
+ this.out(`真实探测: 通过(${state.mode},${result.ms}ms)`);
921
+ return 0;
922
+ }
923
+ this.err(`真实探测: 失败(${state.mode}:${result.error})`);
924
+ return 1;
925
+ }
926
+ let image;
927
+ try {
928
+ image = this.ensureBaseImage();
929
+ } catch (error) {
930
+ this.err(`真实探测: 失败(${error.message})`);
931
+ return 1;
932
+ }
933
+ const runtimeCommand = this.runtimeCommand();
934
+ const integration = await this.buildContainerIntegration({ runtimeCommand });
935
+ const name = `manyoyo-probe-${process.pid}`;
936
+ const args = ['run', '--rm', '--init', '--name', name, '--shm-size=1g', ...integration.extraArgs];
937
+ for (const volume of integration.volumes) args.push('--volume', volume);
938
+ for (const entry of integration.env) args.push('--env', entry);
939
+ args.push(image, 'sh', '-c', CONTAINER_PROBE_SCRIPT);
940
+ const started = Date.now();
941
+ const result = this.runtimeCall(args, { timeout: CONTAINER_PROBE_TIMEOUT_MS });
942
+ if (result.status !== 0) this.runtimeCall(['rm', '-f', name]); // 超时时容器客户端被杀,容器本身可能还在
943
+ if (result.status === 0 && /manyoyo-ok/.test(result.stdout)) {
944
+ this.out(`真实探测: 通过(default,${Date.now() - started}ms)`);
945
+ return 0;
946
+ }
947
+ const reason = `${result.stdout}${result.stderr}`.trim().split('\n').filter(Boolean).slice(-1)[0] || `退出码 ${result.status}`;
948
+ this.err(`真实探测: 失败(default:容器里没能打开页面,${reason})`);
949
+ return 1;
950
+ }
951
+
902
952
  logs() {
903
953
  const state = this.readState();
904
954
  if (state.mode === 'vnc') {
@@ -933,6 +983,9 @@ class PlaywrightPlugin {
933
983
  if (action === 'status') {
934
984
  return await this.status();
935
985
  }
986
+ if (action === 'probe') {
987
+ return await this.probe();
988
+ }
936
989
  if (action === 'logs') {
937
990
  return this.logs();
938
991
  }