@jonathanleelx/meddle 0.4.0-beta.9 → 0.4.1-beta.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 CHANGED
@@ -34,7 +34,7 @@ irm https://raw.githubusercontent.com/JonathanLee-LX/meddle/main/scripts/install
34
34
  | Windows x64 | `meddle-windows-x64.exe` |
35
35
  | Windows ARM64 | `meddle-windows-arm64.exe` |
36
36
 
37
- 二进制产物随 [GitHub Release](https://github.com/JonathanLee-LX/meddle/releases) 发布,也可手动下载。安装目录默认 `~/.meddle/bin`,可通过 `MEDDLE_BIN_DIR` 环境变量自定义。
37
+ 二进制产物随 [GitHub Release](https://github.com/JonathanLee-LX/meddle/releases) 发布,也可手动下载。安装目录默认 `~/.local/bin`(已有 `~/.meddle/bin` 旧安装时原地升级),可通过 `MEDDLE_BIN_DIR` 环境变量自定义。`meddle update` 始终更新到当前运行二进制所在目录。
38
38
 
39
39
  ### 命令
40
40
 
@@ -25,6 +25,7 @@ const {
25
25
  getAutoUpdate,
26
26
  setAutoUpdate,
27
27
  inferChannel,
28
+ resolveUpdateBinDir,
28
29
  } = require('../lib/update-check')
29
30
 
30
31
  const PACKAGE_NAME = '@jonathanleelx/meddle'
@@ -41,6 +42,27 @@ function isNpmInstall() {
41
42
  return installMethod === 'npm'
42
43
  }
43
44
 
45
+ function formatBytes(bytes) {
46
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
47
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`
48
+ return `${bytes} B`
49
+ }
50
+
51
+ const PROGRESS_WIDTH = 40
52
+
53
+ function drawProgressBar({ received, total, attempt, maxAttempts }) {
54
+ const label = attempt > 1 ? ` (第 ${attempt}/${maxAttempts} 次尝试)` : ''
55
+ if (total > 0) {
56
+ const ratio = Math.min(1, received / total)
57
+ const filled = Math.round(PROGRESS_WIDTH * ratio)
58
+ const bar = '#'.repeat(filled) + '-'.repeat(PROGRESS_WIDTH - filled)
59
+ const line = ` [${bar}] ${(ratio * 100).toFixed(1)}% ${formatBytes(received)}/${formatBytes(total)}${label}`
60
+ process.stdout.write(`\r${line}`)
61
+ } else {
62
+ process.stdout.write(`\r 下载中 ${formatBytes(received)}${label}...`)
63
+ }
64
+ }
65
+
44
66
  function showHelp() {
45
67
  output.plain(`
46
68
  meddle update - 检查并升级 meddle
@@ -82,20 +104,35 @@ async function resolveLatest({ force = false, notify = true, channel = 'stable',
82
104
 
83
105
  async function installVersion(version) {
84
106
  if (!isNpmInstall()) {
85
- const binDir = process.env.MEDDLE_BIN_DIR || path.join(home, 'bin')
107
+ const binDir = resolveUpdateBinDir({ home })
86
108
  const destFile = path.join(binDir, os.platform() === 'win32' ? 'meddle.exe' : 'meddle')
87
109
  output.info(`下载 meddle ${version} (${os.platform()}/${os.arch()})...`)
110
+ let progressShown = false
111
+ const showProgress = output.isJsonMode() ? () => {} : drawProgressBar
88
112
  try {
89
113
  const result = await downloadBinaryAsset({
90
114
  version,
91
115
  destFile,
92
116
  platform: os.platform(),
93
117
  arch: os.arch(),
118
+ onAttempt: (attempt, maxAttempts) => {
119
+ if (attempt > 1) {
120
+ process.stdout.write('\n')
121
+ progressShown = false
122
+ output.info(`连接中断,正在重试 (${attempt}/${maxAttempts})...`)
123
+ }
124
+ },
125
+ onProgress: (p) => {
126
+ showProgress(p)
127
+ progressShown = true
128
+ },
94
129
  })
130
+ if (progressShown) process.stdout.write('\n')
95
131
  output.success(`已安装 meddle ${version} 到 ${result.installed}`)
96
132
  if (result.backup) output.info(`旧版本已备份到 ${result.backup}`)
97
133
  output.info('重启 meddle 后新版本生效')
98
134
  } catch (err) {
135
+ if (progressShown) process.stdout.write('\n')
99
136
  output.error(`升级失败: ${err && err.message ? err.message : err}`)
100
137
  process.exit(1)
101
138
  }
@@ -280,10 +280,13 @@ function sha256Hex(buffer) {
280
280
  * thrown (Windows commonly fails with EPERM on a running binary).
281
281
  *
282
282
  * The payload download is retried (default 3 attempts) because binaries are
283
- * large (100MB+) and flaky connections drop mid-body.
283
+ * large (100MB+) and flaky connections drop mid-body. Progress is reported
284
+ * via `onProgress` while streaming (received/total bytes), and `onAttempt`
285
+ * fires before each attempt (skipping retries is visible to callers).
284
286
  * @param {{ version: string, destFile: string, platform: string, arch: string,
285
287
  * baseUrl?: string, fetchImpl?: Function, timeoutMs?: number,
286
288
  * retries?: number, retryDelayMs?: number,
289
+ * onProgress?: Function, onAttempt?: Function,
287
290
  * fsImpl?: object }} opts
288
291
  * @returns {Promise<{ installed: string, backup: string, version: string }>}
289
292
  */
@@ -295,25 +298,63 @@ async function downloadBinaryAsset(opts) {
295
298
  const payloadTimeout = opts.timeoutMs || DEFAULT_DOWNLOAD_TIMEOUT_MS
296
299
  const maxRetries = opts.retries === undefined ? 3 : opts.retries
297
300
  const retryDelayMs = opts.retryDelayMs === undefined ? 1000 : opts.retryDelayMs
301
+ const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : () => {}
302
+ const onAttempt = typeof opts.onAttempt === 'function' ? opts.onAttempt : () => {}
298
303
 
299
304
  const asset = getAssetName({ platform, arch })
300
305
  const assetUrl = `${baseUrl}/v${version}/${asset}`
301
306
  const shaUrl = `${assetUrl}.sha256`
302
307
 
303
- // The binary is large (100MB+); the checksum sidecar is tiny. Use a long
304
- // timeout for the payload and the regular timeout for the metadata fetch.
308
+ // The binary is large (100MB+); the checksum sidecar is tiny. Use an IDLE
309
+ // timeout for the payload (reset on every received chunk — a slow-but-steady
310
+ // transfer is never killed by a total wall-time budget) and the regular
311
+ // timeout for the metadata fetch.
305
312
  const fetchPayload = async () => {
306
313
  let lastError = null
307
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
308
- if (attempt > 0 && retryDelayMs > 0) {
309
- await new Promise((r) => setTimeout(r, retryDelayMs * attempt))
314
+ for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
315
+ if (attempt > 1 && retryDelayMs > 0) {
316
+ await new Promise((r) => setTimeout(r, retryDelayMs * (attempt - 1)))
310
317
  }
318
+ onAttempt(attempt, maxRetries + 1)
319
+ let idleTimer = null
320
+ const controller = new AbortController()
321
+ const resetIdle = () => {
322
+ if (idleTimer) clearTimeout(idleTimer)
323
+ idleTimer = setTimeout(() => {
324
+ controller.abort(new Error('download stalled (idle timeout)'))
325
+ }, payloadTimeout)
326
+ if (typeof idleTimer.unref === 'function') idleTimer.unref()
327
+ }
328
+ resetIdle()
311
329
  try {
312
- const response = await doFetch(assetUrl, { signal: AbortSignal.timeout(payloadTimeout) })
330
+ const response = await doFetch(assetUrl, { signal: controller.signal })
313
331
  if (!response.ok) throw new Error(`download failed (${response.status})`)
314
- return Buffer.from(await response.arrayBuffer())
332
+ const total = Number(response.headers.get('content-length')) || 0
333
+ const chunks = []
334
+ let received = 0
335
+ if (response.body && typeof response.body.getReader === 'function') {
336
+ const reader = response.body.getReader()
337
+ for (;;) {
338
+ resetIdle()
339
+ const { done, value } = await reader.read()
340
+ if (done) break
341
+ chunks.push(Buffer.from(value))
342
+ received += value.byteLength
343
+ onProgress({ received, total })
344
+ }
345
+ } else {
346
+ resetIdle()
347
+ const buf = Buffer.from(await response.arrayBuffer())
348
+ chunks.push(buf)
349
+ received = buf.length
350
+ onProgress({ received, total })
351
+ }
352
+ if (idleTimer) clearTimeout(idleTimer)
353
+ return Buffer.concat(chunks)
315
354
  } catch (err) {
355
+ if (idleTimer) clearTimeout(idleTimer)
316
356
  lastError = err
357
+ if (attempt > maxRetries) break
317
358
  }
318
359
  }
319
360
  throw lastError || new Error(`download failed for ${assetUrl}`)
@@ -408,6 +449,24 @@ function printUpdateNotice(info) {
408
449
  )
409
450
  }
410
451
 
452
+ /**
453
+ * Resolve where `meddle update` should install the new binary.
454
+ * 1. MEDDLE_BIN_DIR env (explicit override)
455
+ * 2. the directory of the RUNNING binary — in-place updates, so installs on
456
+ * legacy (~/.meddle/bin) or custom paths stay where they are
457
+ * 3. <home>/bin fallback (npm installs / no running binary)
458
+ * @param {{ home: string, execPath?: string, platform?: string }} opts
459
+ * @returns {string}
460
+ */
461
+ function resolveUpdateBinDir({ home, execPath = process.execPath, platform = os.platform() } = {}) {
462
+ if (process.env.MEDDLE_BIN_DIR) return process.env.MEDDLE_BIN_DIR
463
+ const p = platform === 'win32' ? path.win32 : path
464
+ const exeName = p.basename(execPath).toLowerCase()
465
+ const expected = platform === 'win32' ? 'meddle.exe' : 'meddle'
466
+ if (exeName === expected) return p.dirname(execPath)
467
+ return p.join(home, 'bin')
468
+ }
469
+
411
470
  /**
412
471
  * Fire-and-forget startup check. Never blocks, never throws, never keeps the
413
472
  * process alive. When auto-update is enabled (binary installs) the new binary
@@ -430,7 +489,7 @@ function isSameBinary(a, b) {
430
489
  function runAsyncUpdateCheck(opts) {
431
490
  const home = opts.home || resolveMeddleHome()
432
491
  const installMethod = opts.installMethod || getInstallMethod({ moduleDir: __dirname })
433
- const binDir = opts.binDir || process.env.MEDDLE_BIN_DIR || path.join(home, 'bin')
492
+ const binDir = opts.binDir || resolveUpdateBinDir({ home, execPath: opts.execPath || process.execPath })
434
493
  const delayMs = opts.delayMs === undefined ? 1500 : opts.delayMs
435
494
  const execPath = opts.execPath || process.execPath
436
495
  const exeBase = path.basename(execPath).toLowerCase()
@@ -493,6 +552,7 @@ module.exports = {
493
552
  downloadBinaryAsset,
494
553
  getAutoUpdate,
495
554
  setAutoUpdate,
555
+ resolveUpdateBinDir,
496
556
  runAsyncUpdateCheck,
497
557
  DEFAULT_DOWNLOAD_TIMEOUT_MS,
498
558
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jonathanleelx/meddle",
3
- "version": "0.4.0-beta.9",
3
+ "version": "0.4.1-beta.1",
4
4
  "description": "A simple development proxy server with custom routing rules, mock responses, HTTPS interception, and a plugin system",
5
5
  "type": "commonjs",
6
6
  "main": "index.js",