herdr-remote 0.2.3 → 0.2.5

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/src/i18n/zh.js CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  module.exports = {
6
6
  'app.name': 'Herdr Remote',
7
- 'app.tagline': '在浏览器里远程使用你的 Herdr 工作区',
7
+ 'app.tagline': '在浏览器中使用 Herdr 工作区',
8
8
 
9
9
  'common.yes': '是',
10
10
  'common.no': '否',
@@ -23,11 +23,11 @@ module.exports = {
23
23
  'common.disabled': '已停用',
24
24
  'common.loading': '处理中…',
25
25
  'common.save': '保存设置',
26
- 'common.saved': '配置已保存到 {path}',
26
+ 'common.saved': '配置已保存至 {path}',
27
27
  'common.back': '返回',
28
28
  'common.cancel': '取消',
29
29
  'common.confirm': '确认',
30
- 'common.pid': '进程 {pid}',
30
+ 'common.pid': 'PID {pid}',
31
31
  'common.notApplicable': '不适用',
32
32
 
33
33
  'nav.overview': '概览',
@@ -40,12 +40,12 @@ module.exports = {
40
40
 
41
41
  'mode.local': '仅本机',
42
42
  'mode.lan': '局域网 / Tailscale',
43
- 'mode.official': '官方 relay(无需自建服务器)',
44
- 'mode.remote': '自建 relay',
45
- 'mode.local.description': 'relay 监听 127.0.0.1,只有本机浏览器能访问。',
46
- 'mode.lan.description': 'relay 监听 0.0.0.0(所有网卡),局域网或 tailnet 里的手机都能访问。',
47
- 'mode.official.description': '通过 Herdr Remote 官方 relay 从任意网络访问本机,无需部署任何服务器。终端流量会经过一台你无法控制的服务器,但只有你配对过的设备才能打开终端。',
48
- 'mode.remote.description': '不启动本地 relay。本机连接你自建的 relay,这是从外网访问的唯一方式。',
43
+ 'mode.official': '官方 Relay',
44
+ 'mode.remote': '自建 Relay',
45
+ 'mode.local.description': '监听 127.0.0.1,仅本机可访问。',
46
+ 'mode.lan.description': '监听 0.0.0.0,局域网与 Tailscale 设备可访问。',
47
+ 'mode.official.description': '使用官方 Relay 跨网访问,无需自建服务器。',
48
+ 'mode.remote.description': '连接自建 Relay 跨网访问。',
49
49
 
50
50
  'overview.title': '状态',
51
51
  'overview.mode': '访问方式',
@@ -56,26 +56,26 @@ module.exports = {
56
56
  'overview.keepalive': '保活服务',
57
57
  'overview.devices': '浏览器',
58
58
  'overview.hosts': '工作站',
59
- 'overview.uptime': 'Relay 运行时长',
59
+ 'overview.uptime': 'Relay 运行时间',
60
60
  'overview.relayLocal': '本地,{bind}:{port}',
61
61
  'overview.relayRemote': '远程,{url}',
62
- 'overview.socketMissing': '未找到 Herdr 在运行吗?',
62
+ 'overview.socketMissing': '未找到,请确认 Herdr 是否运行',
63
63
  'overview.unreachable': '无法访问:{message}',
64
- 'overview.notStarted': '服务未运行,请到「服务」页面启动。',
64
+ 'overview.notStarted': '服务未运行,可前往「服务」启动。',
65
65
 
66
66
  'pair.title': '配对设备',
67
- 'pair.generate': '生成一次性配对码',
68
- 'pair.regenerate': '重新生成配对码',
69
- 'pair.working': '正在启动服务并申请配对码…',
67
+ 'pair.generate': '生成配对码',
68
+ 'pair.regenerate': '重新生成',
69
+ 'pair.working': '生成配对码中…',
70
70
  'pair.code': '配对码',
71
- 'pair.url': '在手机上打开',
71
+ 'pair.url': '访问地址',
72
72
  'pair.expires': '剩余 {minutes}(至 {time})',
73
- 'pair.expired': '配对码已过期,请重新生成。',
74
- 'pair.instructions': '打开链接、输入配对码,之后这台浏览器就长期可用了。',
75
- 'pair.qrHint': '用手机相机扫码即可打开链接。',
76
- 'pair.qrUnavailable': '终端宽度不足以显示二维码,请使用上面的链接。',
73
+ 'pair.expired': '配对码已过期,需重新生成。',
74
+ 'pair.instructions': '打开链接并输入配对码。',
75
+ 'pair.qrHint': '扫描二维码打开链接。',
76
+ 'pair.qrUnavailable': '终端宽度不足,无法显示二维码。',
77
77
  'pair.failed': '生成配对码失败:{message}',
78
- 'pair.hostOffline': 'relay 上还没有工作站连接,请先启动服务。',
78
+ 'pair.hostOffline': 'Relay 未连接工作站,需先启动服务。',
79
79
 
80
80
  'services.title': '服务',
81
81
  'services.start': '启动服务',
@@ -86,39 +86,42 @@ module.exports = {
86
86
  'services.restarted': '服务已重启。',
87
87
  'services.startFailed': '启动服务失败:{message}',
88
88
  'services.stopFailed': '停止服务失败:{message}',
89
- 'services.unsavedBlocked': '有未保存的修改。请先保存,否则服务会用磁盘上的旧配置重启。',
90
- 'services.managedNotice': '服务由 {manager} 托管,已通过保活服务执行此操作。',
89
+ 'services.unsavedBlocked': '配置未保存,请先保存再重启。',
90
+ 'services.managedNotice': ' {manager} 托管,已通过保活服务执行。',
91
91
  'services.logs': '最近日志',
92
- 'services.logEmpty': '暂无日志输出。',
92
+ 'services.logEmpty': '暂无日志。',
93
93
  'services.logRelay': 'Relay',
94
94
  'services.logHost': '主机连接器',
95
95
 
96
96
  'relay.title': 'Relay 设置',
97
97
  'relay.settings': '设置',
98
+ 'relay.locked': '固定',
99
+ 'relay.officialFixed': '官方 relay 地址固定,不可修改。',
100
+ 'relay.officialHint': '官方 relay:地址固定,无需密码。',
98
101
  'relay.password': 'Relay 密码',
99
- 'relay.passwordHint': '需与 relay 上的 RELAY_PASSWORD 一致。留空表示公用 relay。',
102
+ 'relay.passwordHint': '须与 Relay 端的 RELAY_PASSWORD 一致。留空为公用 Relay。',
100
103
  'relay.passwordSet': '已设置',
101
- 'relay.passwordEmpty': '未设置(公用 relay)',
102
- 'relay.passwordSaved': 'Relay 密码已保存,重启服务后生效。',
104
+ 'relay.passwordEmpty': '未设置(公用)',
105
+ 'relay.passwordSaved': 'Relay 密码已保存,重启服务生效。',
103
106
  'relay.showPassword': '显示密码',
104
107
  'relay.hidePassword': '隐藏密码',
105
108
  'relay.identity': '工作站标识',
106
- 'relay.regenerate': '重新生成工作站标识',
107
- 'relay.regenerated': '已生成新的工作站标识,重启服务后会重新注册。',
108
- 'relay.envSnippet': 'relay 服务器启动命令',
109
- 'relay.envHint': '在 relay 主机上执行这条命令。',
110
- 'relay.test': '测试 relay 连接',
111
- 'relay.testOk': 'relay 可访问:版本 {version},已连接 {hosts} 台工作站。',
112
- 'relay.testFailed': 'relay 无法访问:{message}',
113
- 'relay.selectAddress': '选择对外公布的地址',
109
+ 'relay.regenerate': '重新生成标识',
110
+ 'relay.regenerated': '已生成新标识,重启后重新注册。',
111
+ 'relay.envSnippet': 'Relay 启动命令',
112
+ 'relay.envHint': '在 Relay 主机执行此命令。',
113
+ 'relay.test': '测试 Relay 连接',
114
+ 'relay.testOk': 'Relay 可访问:版本 {version},已连接 {hosts} 台工作站。',
115
+ 'relay.testFailed': 'Relay 无法访问:{message}',
116
+ 'relay.selectAddress': '选择公布地址',
114
117
  'relay.listenAddress': '监听地址',
115
- 'relay.listenAddressHint': '这是服务器绑定地址;下面的对外公布地址才是浏览器要打开的地址。',
118
+ 'relay.listenAddressHint': '服务器绑定地址;浏览器访问下方公布地址。',
116
119
  'relay.addressTailscale': 'Tailscale',
117
120
  'relay.addressLan': '局域网',
118
121
  'relay.addressVirtual': '虚拟网卡',
119
122
  'relay.addressLoopback': '回环',
120
- 'relay.noAddresses': '没有找到非回环地址,请先连接网络或启动 Tailscale。',
121
- 'relay.docsHint': '自建 relay 请参阅 docs/self-hosted-relay.zh-CN.md',
123
+ 'relay.noAddresses': '未找到非回环地址。',
124
+ 'relay.docsHint': '自建指南:docs/self-hosted-relay.zh-CN.md',
122
125
 
123
126
  'keepalive.title': '后台保活服务',
124
127
  'keepalive.manager': '管理方式',
@@ -132,12 +135,12 @@ module.exports = {
132
135
  'keepalive.failed': '保活操作失败:{message}',
133
136
  'keepalive.unitPath': '单元文件',
134
137
  'keepalive.logsHint': '查看日志:{command}',
135
- 'keepalive.linger': '未登录也开机自启',
136
- 'keepalive.lingerEnabled': '已开启 linger:开机即启动。',
137
- 'keepalive.lingerDisabled': '未开启 linger,服务只在你登录期间运行。',
138
+ 'keepalive.linger': '开机自启',
139
+ 'keepalive.lingerEnabled': '已开启开机自启。',
140
+ 'keepalive.lingerDisabled': '未开启开机自启(仅登录期间运行)。',
138
141
  'keepalive.enableLinger': '开启开机自启',
139
- 'keepalive.lingerDone': '已为 {username} 开启 linger。',
140
- 'keepalive.fallbackNote': '系统没有可用的服务管理器,改用内置的后台守护进程,重启电脑后不会自动恢复。',
142
+ 'keepalive.lingerDone': '已为 {username} 开启开机自启。',
143
+ 'keepalive.fallbackNote': '无系统服务管理器,使用后台守护进程,重启后不自动恢复。',
141
144
 
142
145
  'herdr.title': 'Herdr 集成',
143
146
  'herdr.socketPath': '套接字路径',
@@ -145,12 +148,12 @@ module.exports = {
145
148
  'herdr.plugin': '插件注册',
146
149
  'herdr.pluginRegistered': '已注册到 Herdr。',
147
150
  'herdr.pluginMissing': '尚未注册到 Herdr。',
148
- 'herdr.register': '把本包注册为 Herdr 插件',
149
- 'herdr.unregister': '取消注册 Herdr 插件',
151
+ 'herdr.register': '注册为 Herdr 插件',
152
+ 'herdr.unregister': '取消注册',
150
153
  'herdr.registerDone': '已注册:{path}',
151
- 'herdr.unregisterDone': '插件已取消注册。',
154
+ 'herdr.unregisterDone': '已取消注册。',
152
155
  'herdr.registerFailed': '注册失败:{message}',
153
- 'herdr.cliMissing': 'PATH 中找不到 herdr 命令。',
156
+ 'herdr.cliMissing': 'PATH 中未找到 herdr 命令。',
154
157
 
155
158
  'about.title': '语言与关于',
156
159
  'about.language': '界面语言',
@@ -161,24 +164,24 @@ module.exports = {
161
164
  'about.configPath': '配置文件',
162
165
  'about.statePath': '状态目录',
163
166
  'about.relayPackage': 'Relay 包',
164
- 'about.docs': '自建 relay 指南:docs/self-hosted-relay.zh-CN.md',
167
+ 'about.docs': '自建指南:docs/self-hosted-relay.zh-CN.md',
165
168
 
166
169
  'wizard.title': '首次设置',
167
170
  'wizard.step': '第 {current} / {total} 步',
168
171
  'wizard.languageTitle': '选择语言',
169
- 'wizard.accessTitle': '你打算怎么访问这台机器?',
170
- 'wizard.accessHint': '之后可以在 Relay 页面随时修改。',
171
- 'wizard.addressTitle': '手机应该使用哪个地址?',
172
- 'wizard.relayTitle': '你的 relay 服务器',
172
+ 'wizard.accessTitle': '选择访问方式',
173
+ 'wizard.accessHint': '后续可在 Relay 页面修改。',
174
+ 'wizard.addressTitle': '选择访问地址',
175
+ 'wizard.relayTitle': 'Relay 服务器',
173
176
  'wizard.relayUrlLabel': 'Relay 地址(wss://…)',
174
- 'wizard.relayHint': 'relay 需要已经在运行。搭建方法见 docs/self-hosted-relay.zh-CN.md。',
175
- 'wizard.passwordTitle': 'Relay 密码',
176
- 'wizard.passwordHint': 'relay 启动时设置的 RELAY_PASSWORD。relay 没设密码就留空。',
177
+ 'wizard.relayHint': 'Relay 需先运行。搭建指南见 docs/self-hosted-relay.zh-CN.md。',
178
+ 'wizard.passwordTitle' : 'Relay 密码',
179
+ 'wizard.passwordHint': 'Relay RELAY_PASSWORD。未设留空。',
177
180
  'wizard.finishTitle': '完成',
178
- 'wizard.finishHint': '配置将保存到 {path}。',
181
+ 'wizard.finishHint': '配置将保存至 {path}。',
179
182
  'wizard.startNow': '立即启动服务',
180
- 'wizard.installKeepalive': '让服务在后台保持运行',
181
- 'wizard.finish': '保存并继续',
183
+ 'wizard.installKeepalive': '后台保活',
184
+ 'wizard.finish': '保存并完成',
182
185
 
183
186
  'field.mode': '访问方式',
184
187
  'field.port': 'Relay 端口',
@@ -194,14 +197,14 @@ module.exports = {
194
197
  'placeholder.none': '无',
195
198
 
196
199
  'error.invalidMode': '未知的访问方式。',
197
- 'error.invalidPort': '端口必须是 1 到 65535 之间的数字。',
198
- 'error.invalidLanHost': '浏览器地址必须是可访问的局域网或 Tailscale 地址,不能填写回环地址或 0.0.0.0。',
199
- 'error.invalidRelayUrl': 'Relay 地址需以 wss://、ws://、https:// 或 http:// 开头。',
200
- 'error.invalidPublicUrl': '浏览器地址需以 https:// 或 http:// 开头,不能使用 0.0.0.0 或 ::。',
200
+ 'error.invalidPort': '端口须为 1 到 65535 之间的数字。',
201
+ 'error.invalidLanHost': '浏览器地址须为可访问局域网或 Tailscale 地址,不能为回环或 0.0.0.0。',
202
+ 'error.invalidRelayUrl': 'Relay 地址须以 wss://、ws://、https:// 或 http:// 开头。',
203
+ 'error.invalidPublicUrl': '浏览器地址须以 https:// 或 http:// 开头,不能为 0.0.0.0 或 ::。',
201
204
  'error.invalidLanguage': '未知的语言。',
202
205
  'error.invalidKeepalive': '未知的保活管理方式。',
203
206
  'error.unknownField': '未知的设置项。',
204
- 'error.remoteUrlRequired': '自建 relay 模式必须填写 Relay 地址。',
207
+ 'error.remoteUrlRequired': '自建 Relay 模式须填写 Relay 地址。',
205
208
  'error.saveFailed': '保存配置失败:{message}',
206
209
 
207
210
  'hint.navigate': '↑↓ 移动',
@@ -213,20 +216,21 @@ module.exports = {
213
216
  'hint.mouseOn': 'm 关闭鼠标',
214
217
  'hint.mouseOff': 'm 开启鼠标',
215
218
  'hint.mouseUnsupported': '不支持鼠标',
216
- 'hint.restartRequired': '需要重启服务才能生效。',
217
- 'hint.unsavedChanges': '有未保存的修改 — 按 s 保存。',
219
+ 'hint.restartRequired': '重启服务生效。',
220
+ 'hint.unsavedChanges': '有未保存修改(按 s 保存)。',
218
221
  'hint.save': 's 保存',
219
222
  'hint.editing': '↵ 确认 · esc 取消',
220
223
  'update.title': '更新',
221
224
  'update.check': '检查更新',
222
- 'update.checking': '正在查询 npm',
225
+ 'update.checking': '查询 npm 中…',
223
226
  'update.upToDate': '已是最新({version})',
224
- 'update.available': '有新版本 {version} —— Enter 安装',
225
- 'update.updating': '正在安装 {version}',
226
- 'update.done': '已更新到 {version}',
227
- 'update.restartHint': '更新已安装,重启 herdr-remote 后生效。',
228
- 'update.errorNetwork': '无法连接 npm registry',
227
+ 'update.available': '新版本 {version} 可用(按 Enter 安装)',
228
+ 'update.updating': '安装 {version} 中…',
229
+ 'update.done': '已更新至 {version}',
230
+ 'update.restartHint': '更新已安装,重启 herdr-remote 生效。',
231
+ 'update.errorNetwork': '所有 npm registry 均无法连接',
232
+ 'update.errorNetworkDetail': '无 registry 应答:{message}',
229
233
  'update.errorFailed': '更新失败,可手动执行:npm install -g herdr-remote@latest',
230
- 'update.cannot.source': '当前从源码目录运行 —— 请用 git 更新,而不是 npm',
231
- 'update.cannot.linked': '当前是 npm link 的工作副本 —— 由 npm link 管理',
234
+ 'update.cannot.source': '源码目录运行,请使用 git 更新',
235
+ 'update.cannot.linked': 'npm link 工作副本,由 npm link 管理',
232
236
  };
package/src/service.js CHANGED
@@ -166,6 +166,10 @@ function serviceSpecs(config = loadConfig(), state = ensureRuntime()) {
166
166
  RELAY_AUTH_STATE_FILE: relayAuthStatePath(),
167
167
  RELAY_ALLOWED_ORIGINS: (config.relay.allowedOrigins || []).join(','),
168
168
  RELAY_MAX_CLIENTS_PER_HOST: String(config.relay.maxClientsPerHost),
169
+ RELAY_MAX_HOSTS: String(config.relay.maxHosts),
170
+ RELAY_MAX_PENDING_HANDSHAKES: String(config.relay.maxPendingHandshakes),
171
+ RELAY_MAX_BUFFERED_BYTES_PER_CLIENT: String(config.relay.maxBufferedBytesPerClient),
172
+ RELAY_HOST_RECONNECT_GRACE_MS: String(config.relay.hostReconnectGraceMs),
169
173
  },
170
174
  });
171
175
  }
@@ -377,18 +381,26 @@ async function waitForRelay(config, { attempts = 20, delayMs = 100 } = {}) {
377
381
  }
378
382
 
379
383
  async function waitForHost(config, { attempts = 30, delayMs = 100 } = {}) {
380
- let lastHealth = null;
384
+ const state = ensureRuntime();
385
+ const statusEndpoint = `${resolveAdminOrigin(config)}/api/status`;
386
+ let lastStatus = null;
381
387
  for (let attempt = 0; attempt < attempts; attempt += 1) {
382
388
  try {
383
- lastHealth = await requestJson(healthUrl(config), { timeout: 800 });
384
- if (lastHealth.hosts > 0) return lastHealth;
389
+ lastStatus = await requestJson(statusEndpoint, {
390
+ timeout: 800,
391
+ headers: {
392
+ 'X-Herdr-Host-Id': state.hostId,
393
+ 'X-Herdr-Host-Token': state.hostToken,
394
+ },
395
+ });
396
+ if (lastStatus.hosts?.some((host) => host.id === state.hostId)) return lastStatus;
385
397
  } catch (error) {
386
- lastHealth = { ok: false, message: error.message };
398
+ lastStatus = { ok: false, message: error.message };
387
399
  }
388
400
  await new Promise((resolve) => setTimeout(resolve, delayMs));
389
401
  }
390
- const error = new Error(lastHealth?.message || 'the Herdr host connector did not register with the relay');
391
- error.health = lastHealth;
402
+ const error = new Error(lastStatus?.message || 'the Herdr host connector did not register with the relay');
403
+ error.health = lastStatus;
392
404
  throw error;
393
405
  }
394
406
 
@@ -11,6 +11,7 @@ const {
11
11
  ACCESS_MODES,
12
12
  KEEPALIVE_MANAGERS,
13
13
  LANGUAGES,
14
+ OFFICIAL_RELAY_URL,
14
15
  configDir,
15
16
  configPath,
16
17
  isLoopbackHost,
@@ -40,6 +41,30 @@ const FIELDS = [
40
41
 
41
42
  const EMPTY = '';
42
43
 
44
+ /**
45
+ * The access modes a *person* chooses between.
46
+ *
47
+ * The official relay is stored as `remote` with a known URL, because that is
48
+ * exactly what it is and nothing downstream should have to learn a fourth mode.
49
+ * It is still a separate answer to "how do I reach this workstation", though:
50
+ * one option needs no server and no credentials, the other needs both. Keeping
51
+ * that distinction here — rather than in whichever screen happens to draw it —
52
+ * is what stops the official relay from being displayed as "self-hosted relay"
53
+ * that merely happens to hold our address.
54
+ */
55
+ const SELECTABLE_MODES = ['local', 'lan', 'official', 'remote'];
56
+
57
+ /** Which of `SELECTABLE_MODES` this draft represents. */
58
+ function selectedMode(draft) {
59
+ if (draft.relay.mode === 'remote' && draft.relay.remoteUrl === OFFICIAL_RELAY_URL) return 'official';
60
+ return draft.relay.mode;
61
+ }
62
+
63
+ /** True when the relay is the one we run, so its address and password are ours. */
64
+ function isOfficialRelay(draft) {
65
+ return selectedMode(draft) === 'official';
66
+ }
67
+
43
68
  function clone(value) {
44
69
  return JSON.parse(JSON.stringify(value));
45
70
  }
@@ -121,7 +146,22 @@ function setField(draft, id, rawValue) {
121
146
 
122
147
  switch (id) {
123
148
  case 'mode': {
124
- if (!ACCESS_MODES.includes(value)) return { draft, errorKey: 'error.invalidMode' };
149
+ if (!SELECTABLE_MODES.includes(value)) return { draft, errorKey: 'error.invalidMode' };
150
+ // Picking the official relay fills in its address in the same edit: a
151
+ // remote mode with no URL is not a valid state, and asking for the
152
+ // address we already know would be asking the user to do our filing.
153
+ if (value === 'official') {
154
+ next.relay.mode = 'remote';
155
+ next.relay.remoteUrl = OFFICIAL_RELAY_URL;
156
+ next.relay.publicUrl = EMPTY;
157
+ break;
158
+ }
159
+ // Leaving the official relay clears its address, so the self-hosted URL
160
+ // field is empty and asking to be filled in rather than pre-loaded with
161
+ // an address that belongs to somebody else's server.
162
+ if (value === 'remote' && next.relay.remoteUrl === OFFICIAL_RELAY_URL) {
163
+ next.relay.remoteUrl = EMPTY;
164
+ }
125
165
  next.relay.mode = value;
126
166
  if (value === 'lan'
127
167
  && (isLoopbackHost(next.relay.lanHost) || isUnspecifiedAddress(next.relay.lanHost))) {
@@ -254,8 +294,11 @@ function requiresRestart(before, after) {
254
294
 
255
295
  module.exports = {
256
296
  FIELDS,
297
+ SELECTABLE_MODES,
257
298
  createDraft,
258
299
  fieldsForMode,
300
+ isOfficialRelay,
301
+ selectedMode,
259
302
  getField,
260
303
  getFieldPlaceholder,
261
304
  setField,
package/src/supervisor.js CHANGED
@@ -5,7 +5,7 @@ const { spawn } = require('node:child_process');
5
5
  const { PACKAGE_ROOT, loadConfig, runtimeStatePath, stateDir } = require('./config');
6
6
  const { ensureDir, readJson, writeJsonAtomic } = require('./state');
7
7
  const { baseEnvironment, ensureRuntime, logPath, managedPids, pidAlive, recordManagedPid, serviceSpecs } = require('./service');
8
- const { EXIT_REPLACED } = require('./exit-codes');
8
+ const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
9
9
 
10
10
  const MIN_BACKOFF_MS = 500;
11
11
  const MAX_BACKOFF_MS = 30_000;
@@ -132,6 +132,14 @@ class Supervisor {
132
132
  });
133
133
  return;
134
134
  }
135
+ if (code === EXIT_AUTH_FAILED) {
136
+ this.emit({
137
+ type: 'fatal',
138
+ name,
139
+ message: `${name} stopped: relay authentication failed. Update credentials before restarting it.`,
140
+ });
141
+ return;
142
+ }
135
143
  if (uptimeMs >= HEALTHY_UPTIME_MS) entry.backoffMs = MIN_BACKOFF_MS;
136
144
  this.emit({
137
145
  type: 'exited',
package/src/updater.js CHANGED
@@ -8,12 +8,23 @@
8
8
  // checkout would replace the tree someone is working in.
9
9
 
10
10
  const fs = require('node:fs');
11
+ const os = require('node:os');
11
12
  const path = require('node:path');
12
13
  const { spawn } = require('node:child_process');
13
14
  const { PACKAGE_ROOT } = require('./config');
14
15
 
15
16
  const PACKAGE_NAME = 'herdr-remote';
16
- const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
17
+ const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
18
+
19
+ /**
20
+ * A public mirror, tried last.
21
+ *
22
+ * `registry.npmjs.org` is not reachable from every network this runs on — in
23
+ * mainland China it usually is not — and "could not reach npm registry" on a
24
+ * machine that installs packages perfectly well is a bug report, not a
25
+ * diagnosis. The mirror is read-only and only ever asked for a version number.
26
+ */
27
+ const MIRROR_REGISTRY = 'https://registry.npmmirror.com';
17
28
 
18
29
  function currentVersion() {
19
30
  try {
@@ -50,6 +61,59 @@ function canSelfUpdate() {
50
61
  return installKind() === 'npm';
51
62
  }
52
63
 
64
+ function normalizeRegistry(value) {
65
+ if (typeof value !== 'string') return null;
66
+ const trimmed = value.trim().replace(/\/+$/, '');
67
+ if (!/^https?:\/\//i.test(trimmed)) return null;
68
+ return trimmed;
69
+ }
70
+
71
+ /** The `registry=` line from an npmrc file, if it has one. */
72
+ function registryFromNpmrc(filePath) {
73
+ let contents;
74
+ try {
75
+ contents = fs.readFileSync(filePath, 'utf8');
76
+ } catch {
77
+ return null;
78
+ }
79
+ let found = null;
80
+ for (const rawLine of contents.split(/\r?\n/)) {
81
+ const line = rawLine.trim();
82
+ if (!line || line.startsWith('#') || line.startsWith(';')) continue;
83
+ const match = /^registry\s*=\s*(.+)$/i.exec(line);
84
+ // The last assignment wins, as it does for npm itself.
85
+ if (match) found = normalizeRegistry(match[1].replace(/^["']|["']$/g, ''));
86
+ }
87
+ return found;
88
+ }
89
+
90
+ /**
91
+ * Where to ask, in the order to ask.
92
+ *
93
+ * Whatever npm itself is configured to use comes first: a machine behind a
94
+ * corporate proxy or on a mirror has already answered this question, and
95
+ * ignoring that answer is what made the check fail on a machine where
96
+ * `npm install` works. The public registry and then a public mirror follow, so
97
+ * a private registry that does not carry this package is not the end of it.
98
+ */
99
+ function registryCandidates({ env = process.env, home = os.homedir(), cwd = process.cwd() } = {}) {
100
+ const candidates = [];
101
+ const add = (value) => {
102
+ const normalized = normalizeRegistry(value);
103
+ if (normalized && !candidates.includes(normalized)) candidates.push(normalized);
104
+ };
105
+
106
+ // npm exports its whole config into the environment of anything it runs.
107
+ add(env.npm_config_registry);
108
+ add(env.NPM_CONFIG_REGISTRY);
109
+ add(env.HERDR_REMOTE_REGISTRY);
110
+ add(registryFromNpmrc(path.join(cwd, '.npmrc')));
111
+ add(registryFromNpmrc(path.join(home, '.npmrc')));
112
+ add(DEFAULT_REGISTRY);
113
+ add(MIRROR_REGISTRY);
114
+ return candidates;
115
+ }
116
+
53
117
  /** Compare two `MAJOR.MINOR.PATCH` strings. Returns 1, -1 or 0. */
54
118
  function compareVersions(a, b) {
55
119
  const parse = (value) => String(value)
@@ -66,40 +130,69 @@ function compareVersions(a, b) {
66
130
  return 0;
67
131
  }
68
132
 
69
- /**
70
- * Ask the registry what the current release is.
71
- *
72
- * Never throws: an update check is a convenience, and a machine that is offline
73
- * or behind a proxy should still get a working settings screen.
74
- */
75
- async function checkForUpdate({ timeoutMs = 8000, fetchImpl = globalThis.fetch } = {}) {
76
- const current = currentVersion();
133
+ /** One registry, one question: what is the latest published version? */
134
+ async function askRegistry(registry, { timeoutMs, fetchImpl }) {
77
135
  const controller = new AbortController();
78
136
  const timer = setTimeout(() => controller.abort(), timeoutMs);
79
137
  try {
80
- const response = await fetchImpl(REGISTRY_URL, {
138
+ const response = await fetchImpl(`${registry}/${PACKAGE_NAME}/latest`, {
81
139
  signal: controller.signal,
82
140
  headers: { Accept: 'application/vnd.npm.install-v1+json' },
83
141
  });
84
- if (!response.ok) {
85
- return { ok: false, current, errorKey: 'update.errorNetwork' };
86
- }
142
+ if (!response.ok) return { ok: false, message: `HTTP ${response.status ?? '?'}` };
87
143
  const body = await response.json();
88
144
  const latest = typeof body?.version === 'string' ? body.version : null;
89
- if (!latest) return { ok: false, current, errorKey: 'update.errorNetwork' };
90
- return {
91
- ok: true,
92
- current,
93
- latest,
94
- updateAvailable: compareVersions(latest, current) > 0,
95
- };
96
- } catch {
97
- return { ok: false, current, errorKey: 'update.errorNetwork' };
145
+ if (!latest) return { ok: false, message: 'registry answered without a version' };
146
+ return { ok: true, latest };
147
+ } catch (error) {
148
+ return { ok: false, message: error?.name === 'AbortError' ? 'timed out' : String(error?.message || error) };
98
149
  } finally {
99
150
  clearTimeout(timer);
100
151
  }
101
152
  }
102
153
 
154
+ /**
155
+ * Ask what the current release is.
156
+ *
157
+ * Never throws: an update check is a convenience, and a machine that is offline
158
+ * or behind a proxy should still get a working settings screen. Each configured
159
+ * registry is tried in turn, because the first one is only a guess at which
160
+ * mirror this machine can actually reach.
161
+ */
162
+ async function checkForUpdate({
163
+ timeoutMs = 6000,
164
+ fetchImpl = globalThis.fetch,
165
+ registries = registryCandidates(),
166
+ } = {}) {
167
+ const current = currentVersion();
168
+ const failures = [];
169
+
170
+ for (const registry of registries) {
171
+ const attempt = await askRegistry(registry, { timeoutMs, fetchImpl });
172
+ if (attempt.ok) {
173
+ return {
174
+ ok: true,
175
+ current,
176
+ latest: attempt.latest,
177
+ registry,
178
+ updateAvailable: compareVersions(attempt.latest, current) > 0,
179
+ };
180
+ }
181
+ failures.push(`${registry}: ${attempt.message}`);
182
+ }
183
+
184
+ // The reason is carried out with the failure. "Could not reach npm registry"
185
+ // with nothing after it leaves the user guessing between DNS, a proxy, a
186
+ // firewall and a mirror that does not carry the package.
187
+ return {
188
+ ok: false,
189
+ current,
190
+ errorKey: 'update.errorNetwork',
191
+ message: failures.join('; '),
192
+ triedRegistries: registries,
193
+ };
194
+ }
195
+
103
196
  /**
104
197
  * Install the newest release over this one.
105
198
  *
@@ -113,6 +206,11 @@ function performUpdate({
113
206
  // Seam for tests: the suite runs from a checkout, where the guard below is
114
207
  // correctly the only reachable outcome.
115
208
  installKindImpl = installKind,
209
+ // The registry that just answered the version check. Installing from
210
+ // somewhere the machine has been shown to reach beats installing from a
211
+ // default it may have no route to. Empty means "whatever npm is configured
212
+ // with", which is the right answer when no check has run.
213
+ registry = '',
116
214
  } = {}) {
117
215
  return new Promise((resolve) => {
118
216
  const kind = installKindImpl();
@@ -120,7 +218,10 @@ function performUpdate({
120
218
  resolve({ ok: false, errorKey: `update.cannot.${kind}` });
121
219
  return;
122
220
  }
123
- const child = spawnImpl('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], {
221
+ const args = ['install', '-g', `${PACKAGE_NAME}@latest`];
222
+ const normalizedRegistry = normalizeRegistry(registry);
223
+ if (normalizedRegistry) args.push('--registry', normalizedRegistry);
224
+ const child = spawnImpl('npm', args, {
124
225
  encoding: 'utf8',
125
226
  timeout: timeoutMs,
126
227
  });
@@ -139,10 +240,13 @@ function performUpdate({
139
240
 
140
241
  module.exports = {
141
242
  PACKAGE_NAME,
243
+ DEFAULT_REGISTRY,
244
+ MIRROR_REGISTRY,
142
245
  canSelfUpdate,
143
246
  checkForUpdate,
144
247
  compareVersions,
145
248
  currentVersion,
146
249
  installKind,
147
250
  performUpdate,
251
+ registryCandidates,
148
252
  };