herdr-remote 0.2.32 → 0.2.34

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.
Files changed (135) hide show
  1. package/README.md +3 -1
  2. package/README.zh-CN.md +2 -1
  3. package/dist/connector/agent-watch.js +16 -3
  4. package/dist/connector/herdr-prefix-keys.js +28 -0
  5. package/dist/connector/host-connector.js +6 -20
  6. package/dist/connector/host-hello.js +32 -0
  7. package/dist/herdr-api.js +7 -1
  8. package/dist/herdr-command.js +7 -2
  9. package/dist/i18n/en.js +5 -18
  10. package/dist/i18n/zh.js +5 -18
  11. package/dist/pty-session.js +27 -2
  12. package/dist/socket-discovery.js +16 -6
  13. package/dist/terminal-font/formats.js +26 -6
  14. package/dist/terminal-font/terminals.js +19 -3
  15. package/dist/tui/App.js +3 -25
  16. package/dist/tui/api.js +3 -3
  17. package/dist/tui/screens/About.js +18 -27
  18. package/dist/update-command.js +16 -19
  19. package/dist/updater.js +91 -48
  20. package/herdr-plugin.toml +2 -2
  21. package/node_modules/herdr-remote-relay/LICENSE +21 -0
  22. package/node_modules/herdr-remote-relay/README.md +76 -0
  23. package/node_modules/herdr-remote-relay/README.zh-CN.md +76 -0
  24. package/node_modules/herdr-remote-relay/bin/herdr-remote-relay.js +116 -0
  25. package/node_modules/herdr-remote-relay/config.example.json +18 -0
  26. package/node_modules/herdr-remote-relay/deploy/cloudflared/config.yml.example +9 -0
  27. package/node_modules/herdr-remote-relay/deploy/docker/compose.yaml +23 -0
  28. package/node_modules/herdr-remote-relay/deploy/nginx/herdr-remote-relay.conf.example +49 -0
  29. package/node_modules/herdr-remote-relay/deploy/systemd/herdr-remote-relay.service +41 -0
  30. package/node_modules/herdr-remote-relay/deploy/systemd/relay.env.example +25 -0
  31. package/node_modules/herdr-remote-relay/dist/auth-store.d.ts +114 -0
  32. package/node_modules/herdr-remote-relay/dist/auth-store.js +289 -0
  33. package/node_modules/herdr-remote-relay/dist/metrics.d.ts +76 -0
  34. package/node_modules/herdr-remote-relay/dist/metrics.js +249 -0
  35. package/node_modules/herdr-remote-relay/dist/protocol/frames.d.ts +27 -0
  36. package/node_modules/herdr-remote-relay/dist/protocol/frames.js +110 -0
  37. package/node_modules/herdr-remote-relay/dist/protocol/herdr-keys.d.ts +8 -0
  38. package/node_modules/herdr-remote-relay/dist/protocol/herdr-keys.js +34 -0
  39. package/node_modules/herdr-remote-relay/dist/protocol/http.d.ts +165 -0
  40. package/node_modules/herdr-remote-relay/dist/protocol/http.js +3 -0
  41. package/node_modules/herdr-remote-relay/dist/protocol/index.d.ts +7 -0
  42. package/node_modules/herdr-remote-relay/dist/protocol/index.js +9 -0
  43. package/node_modules/herdr-remote-relay/dist/protocol/messages.d.ts +362 -0
  44. package/node_modules/herdr-remote-relay/dist/protocol/messages.js +20 -0
  45. package/node_modules/herdr-remote-relay/dist/protocol/paste.d.ts +14 -0
  46. package/node_modules/herdr-remote-relay/dist/protocol/paste.js +50 -0
  47. package/node_modules/herdr-remote-relay/dist/protocol/release.d.ts +8 -0
  48. package/node_modules/herdr-remote-relay/dist/protocol/release.js +17 -0
  49. package/node_modules/herdr-remote-relay/dist/protocol/terminal.d.ts +79 -0
  50. package/node_modules/herdr-remote-relay/dist/protocol/terminal.js +150 -0
  51. package/node_modules/herdr-remote-relay/dist/relay-config.d.ts +74 -0
  52. package/node_modules/herdr-remote-relay/dist/relay-config.js +316 -0
  53. package/node_modules/herdr-remote-relay/dist/relay-server.d.ts +64 -0
  54. package/node_modules/herdr-remote-relay/dist/relay-server.js +194 -0
  55. package/node_modules/herdr-remote-relay/dist/scroll-input.d.ts +17 -0
  56. package/node_modules/herdr-remote-relay/dist/scroll-input.js +124 -0
  57. package/node_modules/herdr-remote-relay/dist/server/client-channel.d.ts +3 -0
  58. package/node_modules/herdr-remote-relay/dist/server/client-channel.js +396 -0
  59. package/node_modules/herdr-remote-relay/dist/server/font-proxy.d.ts +19 -0
  60. package/node_modules/herdr-remote-relay/dist/server/font-proxy.js +155 -0
  61. package/node_modules/herdr-remote-relay/dist/server/host-broadcasts.d.ts +27 -0
  62. package/node_modules/herdr-remote-relay/dist/server/host-broadcasts.js +83 -0
  63. package/node_modules/herdr-remote-relay/dist/server/host-channel.d.ts +3 -0
  64. package/node_modules/herdr-remote-relay/dist/server/host-channel.js +271 -0
  65. package/node_modules/herdr-remote-relay/dist/server/http-api.d.ts +3 -0
  66. package/node_modules/herdr-remote-relay/dist/server/http-api.js +214 -0
  67. package/node_modules/herdr-remote-relay/dist/server/maintenance.d.ts +5 -0
  68. package/node_modules/herdr-remote-relay/dist/server/maintenance.js +64 -0
  69. package/node_modules/herdr-remote-relay/dist/server/requests.d.ts +34 -0
  70. package/node_modules/herdr-remote-relay/dist/server/requests.js +142 -0
  71. package/node_modules/herdr-remote-relay/dist/server/sessions.d.ts +36 -0
  72. package/node_modules/herdr-remote-relay/dist/server/sessions.js +230 -0
  73. package/node_modules/herdr-remote-relay/dist/server/sockets.d.ts +12 -0
  74. package/node_modules/herdr-remote-relay/dist/server/sockets.js +64 -0
  75. package/node_modules/herdr-remote-relay/dist/server/status.d.ts +11 -0
  76. package/node_modules/herdr-remote-relay/dist/server/status.js +99 -0
  77. package/node_modules/herdr-remote-relay/dist/server/transport.d.ts +16 -0
  78. package/node_modules/herdr-remote-relay/dist/server/transport.js +110 -0
  79. package/node_modules/herdr-remote-relay/dist/server/types.d.ts +131 -0
  80. package/node_modules/herdr-remote-relay/dist/server/types.js +3 -0
  81. package/node_modules/herdr-remote-relay/dist/server/version.d.ts +2 -0
  82. package/node_modules/herdr-remote-relay/dist/server/version.js +5 -0
  83. package/node_modules/herdr-remote-relay/dist/state.d.ts +6 -0
  84. package/node_modules/herdr-remote-relay/dist/state.js +47 -0
  85. package/node_modules/herdr-remote-relay/package.json +76 -0
  86. package/node_modules/herdr-remote-relay/web/dist/assets/index-BeveQ9Di.js +61 -0
  87. package/node_modules/herdr-remote-relay/web/dist/assets/index-XYYRsgSb.css +32 -0
  88. package/node_modules/herdr-remote-relay/web/dist/assets/vendor-react-CYSfZuHu.js +17 -0
  89. package/node_modules/herdr-remote-relay/web/dist/assets/vendor-xterm-BhCxil_4.js +9 -0
  90. package/node_modules/herdr-remote-relay/web/dist/fonts/SymbolsNerdFontMono-Regular.woff2 +0 -0
  91. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/OFL.txt +93 -0
  92. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-400-italic.woff2 +0 -0
  93. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-400-normal.woff2 +0 -0
  94. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-700-italic.woff2 +0 -0
  95. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-700-normal.woff2 +0 -0
  96. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-400-italic.woff2 +0 -0
  97. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-400-normal.woff2 +0 -0
  98. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-700-italic.woff2 +0 -0
  99. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-700-normal.woff2 +0 -0
  100. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/OFL.txt +93 -0
  101. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-400-normal.woff2 +0 -0
  102. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-700-normal.woff2 +0 -0
  103. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-ext-400-normal.woff2 +0 -0
  104. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-ext-700-normal.woff2 +0 -0
  105. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/OFL.txt +93 -0
  106. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-italic.woff2 +0 -0
  107. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  108. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-700-italic.woff2 +0 -0
  109. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-700-normal.woff2 +0 -0
  110. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-italic.woff2 +0 -0
  111. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  112. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-700-italic.woff2 +0 -0
  113. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-700-normal.woff2 +0 -0
  114. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/OFL.txt +93 -0
  115. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-400-italic.woff2 +0 -0
  116. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
  117. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-700-italic.woff2 +0 -0
  118. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-700-normal.woff2 +0 -0
  119. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-italic.woff2 +0 -0
  120. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  121. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-700-italic.woff2 +0 -0
  122. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-700-normal.woff2 +0 -0
  123. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/OFL.txt +93 -0
  124. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-400-italic.woff2 +0 -0
  125. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-400-normal.woff2 +0 -0
  126. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-700-italic.woff2 +0 -0
  127. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-700-normal.woff2 +0 -0
  128. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-400-italic.woff2 +0 -0
  129. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-400-normal.woff2 +0 -0
  130. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-700-italic.woff2 +0 -0
  131. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-700-normal.woff2 +0 -0
  132. package/node_modules/herdr-remote-relay/web/dist/index.html +36 -0
  133. package/package.json +6 -3
  134. package/dist/relay-updater.js +0 -81
  135. package/dist/tui/screens/useRelayUpdate.js +0 -122
@@ -2,18 +2,17 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { theme } from '../theme.js';
5
- import { useRelayUpdate } from './useRelayUpdate.js';
6
5
  import { Message, Panel, Row, Selectable } from '../components/common.js';
7
- import { canSelfUpdate, checkForUpdate, configPath, currentVersion, detectLocale, installKind, performUpdate, saveDraft, setField, stateDir, whileServicesStopped, } from '../api.js';
6
+ import { canSelfUpdate, cliTarballUrl, checkForUpdate, configPath, currentVersion, detectLocale, installKind, installedRelayVersion, performUpdate, runsLocalRelay, saveDraft, setField, stateDir, whileServicesStopped, } from '../api.js';
8
7
  /** `https://registry.npmmirror.com` → `registry.npmmirror.com`, for one line of text. */
9
- const registryHost = (registry) => registry.replace(/^https?:\/\//, '');
8
+ const sourceHost = (source) => source.replace(/^https?:\/\//, '');
10
9
  export function About({ ctx }) {
11
10
  const { t, draft } = ctx;
12
11
  const [selected, setSelected] = useState('auto');
13
12
  const [update, setUpdate] = useState({ phase: 'idle' });
14
- /** Why the install will come from somewhere other than npm's own registry. */
13
+ /** Why the install will come from somewhere other than where some sources said. */
15
14
  const [sourceNote, setSourceNote] = useState(null);
16
- const checkRef = useState(() => ({ current: { registry: '', sources: [] } }))[0];
15
+ const checkRef = useState(() => ({ current: { source: '', sources: [] } }))[0];
17
16
  const detected = detectLocale({ preference: 'auto' });
18
17
  const languageOptions = [
19
18
  { id: 'auto', label: t('about.languageAuto', { detected }) },
@@ -43,24 +42,19 @@ export function About({ ctx }) {
43
42
  return t('update.check');
44
43
  }
45
44
  })();
46
- const relay = useRelayUpdate(ctx);
47
- const options = [
48
- ...languageOptions,
49
- { id: 'update', label: updateLabel },
50
- ...(relay.visible ? [{ id: 'relay-update', label: relay.label }] : []),
51
- ];
45
+ const options = [...languageOptions, { id: 'update', label: updateLabel }];
52
46
  /** Show a check's answer, whether this screen asked or the TUI did on opening. */
53
47
  const applyCheck = (result) => {
54
- checkRef.current = { registry: result.registry ?? '', sources: result.sources ?? [] };
55
- // A mirror that has not synced the release yet used to be the whole
48
+ checkRef.current = { source: result.source ?? '', sources: result.sources ?? [] };
49
+ // A registry that has not synced the release yet used to be the whole
56
50
  // answer. It is still asked, and said to be behind.
57
51
  const behind = result.behind ?? [];
58
- setSourceNote(result.updateAvailable && behind.length > 0 && result.registry
52
+ setSourceNote(result.updateAvailable && behind.length > 0 && result.source
59
53
  ? {
60
54
  registries: behind
61
- .map((entry) => `${registryHost(entry.registry)} ${entry.version ?? ''}`.trim())
55
+ .map((entry) => `${sourceHost(entry.source)} ${entry.version ?? ''}`.trim())
62
56
  .join(', '),
63
- source: registryHost(result.registry),
57
+ source: sourceHost(result.source),
64
58
  }
65
59
  : null);
66
60
  setUpdate(result.updateAvailable
@@ -79,7 +73,7 @@ export function About({ ctx }) {
79
73
  const result = await checkForUpdate();
80
74
  if (!result.ok) {
81
75
  setUpdate({ phase: 'error', messageKey: result.errorKey ?? 'update.errorNetwork' });
82
- // Which registries were tried, and what each of them said. Without this
76
+ // Which sources were tried, and what each of them said. Without this
83
77
  // the row reads "could not reach npm registry" on a machine where
84
78
  // `npm install` works perfectly, and there is nothing to act on.
85
79
  const { message } = result;
@@ -93,13 +87,17 @@ export function About({ ctx }) {
93
87
  const runUpdate = async (latest) => {
94
88
  setUpdate({ phase: 'updating', latest, attempt: 1 });
95
89
  const result = await whileServicesStopped(ctx.config, () => performUpdate({
96
- registry: checkRef.current.registry,
90
+ source: checkRef.current.source,
97
91
  sources: checkRef.current.sources,
98
92
  version: latest,
99
93
  onAttempt: ({ attempt }) => setUpdate({ phase: 'updating', latest, attempt }),
100
94
  }));
101
95
  if (!result.ok) {
102
- const params = { version: latest, installed: result.installed ?? '' };
96
+ const params = {
97
+ version: latest,
98
+ installed: result.installed ?? '',
99
+ url: cliTarballUrl(latest),
100
+ };
103
101
  setUpdate({ phase: 'error', messageKey: result.errorKey ?? 'update.errorFailed', params });
104
102
  // npm's own words: "update failed" alone is what left this unfixable.
105
103
  ctx.notify((t) => result.summary
@@ -149,10 +147,6 @@ export function About({ ctx }) {
149
147
  }
150
148
  };
151
149
  const activate = (id) => {
152
- if (id === 'relay-update') {
153
- relay.activate();
154
- return;
155
- }
156
150
  if (id === 'update') {
157
151
  activateUpdate();
158
152
  return;
@@ -174,8 +168,5 @@ export function About({ ctx }) {
174
168
  }, onHover: () => setSelected(option.id), children: `${option.label}${draft.ui.language === option.id ? ' ✓' : ''}` }, option.id))) }), _jsx(Text, { color: theme.muted, children: t('update.title') }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: [_jsx(Selectable, { selected: selected === 'update', onSelect: () => {
175
169
  setSelected('update');
176
170
  activateUpdate();
177
- }, onHover: () => setSelected('update'), children: updateLabel }), sourceNote ? (_jsx(Text, { color: theme.muted, children: ` ${t('update.mirrorBehind', sourceNote)}` })) : null, relay.visible ? (_jsx(Selectable, { selected: selected === 'relay-update', onSelect: () => {
178
- setSelected('relay-update');
179
- relay.activate();
180
- }, onHover: () => setSelected('relay-update'), children: relay.label })) : null] }), _jsx(Row, { label: t('about.version'), children: _jsx(Text, { color: theme.muted, children: currentVersion() }) }), relay.visible ? (_jsx(Row, { label: t('about.relayPackage'), children: _jsx(Text, { color: theme.muted, children: relay.installed ?? '?' }) })) : null, _jsx(Row, { label: t('about.configPath'), children: _jsx(Text, { color: theme.muted, children: configPath() }) }), _jsx(Row, { label: t('about.statePath'), children: _jsx(Text, { color: theme.muted, children: stateDir() }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('about.docs') }) }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
171
+ }, onHover: () => setSelected('update'), children: updateLabel }), sourceNote ? (_jsx(Text, { color: theme.muted, children: ` ${t('update.mirrorBehind', sourceNote)}` })) : null] }), _jsx(Row, { label: t('about.version'), children: _jsx(Text, { color: theme.muted, children: currentVersion() }) }), runsLocalRelay(ctx.config) ? (_jsx(Row, { label: t('about.relayPackage'), children: _jsx(Text, { color: theme.muted, children: installedRelayVersion() ?? '?' }) })) : null, _jsx(Row, { label: t('about.configPath'), children: _jsx(Text, { color: theme.muted, children: configPath() }) }), _jsx(Row, { label: t('about.statePath'), children: _jsx(Text, { color: theme.muted, children: stateDir() }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('about.docs') }) }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
181
172
  }
@@ -6,11 +6,11 @@
6
6
  // still runs the old code, and would start the services as the old release
7
7
  // described them.
8
8
  import { spawnSync } from 'node:child_process';
9
+ import { cliTarballUrl } from 'herdr-remote-relay/protocol';
9
10
  import { loadConfig } from './config.js';
10
11
  import { startAdminBrokerTask, stopAdminBroker, waitForAdminBroker, } from './connector/admin-broker.js';
11
12
  import { cliEntryPoint } from './keepalive/environment.js';
12
13
  import { servicesRunning, startAll, stopAll } from './lifecycle.js';
13
- import { checkForRelayUpdate, performRelayUpdate } from './relay-updater.js';
14
14
  import { checkForUpdate, performUpdate } from './updater.js';
15
15
  import { heldByAnotherProcess } from './updater/in-place.js';
16
16
  const RESTART_TIMEOUT_MS = 120_000;
@@ -27,16 +27,11 @@ function restartWithInstalledRelease() {
27
27
  }
28
28
  export async function runUpdate(t, options = {}) {
29
29
  const config = () => options.config ?? loadConfig();
30
- const { platform = process.platform, checkCli = () => checkForUpdate(), checkRelay = () => checkForRelayUpdate(), installCli = (check, onAttempt) => performUpdate({
31
- registry: check.registry,
30
+ const { platform = process.platform, checkCli = () => checkForUpdate(), installCli = (check, onAttempt) => performUpdate({
31
+ source: check.source,
32
32
  sources: check.sources,
33
33
  version: check.latest,
34
34
  onAttempt,
35
- }), installRelay = (check, onAttempt) => performRelayUpdate({
36
- registry: check.registry,
37
- sources: check.sources,
38
- relayVersion: check.latest ?? '',
39
- onAttempt,
40
35
  }), isRunning = () => servicesRunning(config()), stop = () => stopAll(config()), start = () => startAll(config()), restartInstalled = restartWithInstalledRelease, stopBroker = stopAdminBroker, startBroker = async () => {
41
36
  try {
42
37
  startAdminBrokerTask(t);
@@ -47,20 +42,18 @@ export async function runUpdate(t, options = {}) {
47
42
  return waitForAdminBroker();
48
43
  }, report = (line) => process.stdout.write(`${line}\n`), } = options;
49
44
  report(t('update.checking'));
50
- const [cli, relay] = await Promise.all([checkCli(), checkRelay()]);
45
+ const cli = await checkCli();
51
46
  const { current } = cli;
52
47
  if (!cli.ok) {
53
48
  report(t('update.errorNetworkDetail', { message: cli.message ?? '' }));
54
49
  return { ok: false, current, updated: null, restarted: false, errorKey: cli.errorKey };
55
50
  }
56
- // A new herdr-remote brings the newest relay its range allows; only a
57
- // current one needs the relay installed by itself.
58
- const updated = cli.updateAvailable ? 'cli' : relay.ok && relay.updateAvailable ? 'relay' : null;
59
- if (!updated) {
51
+ if (!cli.updateAvailable) {
60
52
  report(t('update.upToDate', { version: current }));
61
- return { ok: true, current, updated, restarted: false };
53
+ return { ok: true, current, updated: null, restarted: false };
62
54
  }
63
- const version = (updated === 'cli' ? cli.latest : relay.latest) ?? '';
55
+ const updated = 'cli';
56
+ const version = cli.latest ?? '';
64
57
  const wasRunning = isRunning();
65
58
  // Windows refuses to replace files a running process holds, as services
66
59
  // started by older releases do. Elsewhere they keep serving until the restart.
@@ -78,18 +71,22 @@ export async function runUpdate(t, options = {}) {
78
71
  report(t(back ? 'update.brokerRestarted' : 'update.brokerRestartFailed'));
79
72
  return back ? 'restarted' : 'failed';
80
73
  };
81
- report(t(updated === 'cli' ? 'update.updating' : 'relayUpdate.updating', { version }));
74
+ report(t('update.updating', { version }));
82
75
  const onAttempt = ({ attempt }) => {
83
76
  if (attempt > 1)
84
77
  report(t('update.updatingRetry', { version, attempt }));
85
78
  };
86
- const result = updated === 'cli' ? await installCli(cli, onAttempt) : await installRelay(relay, onAttempt);
79
+ const result = await installCli(cli, onAttempt);
87
80
  if (!result.ok) {
88
81
  // Nothing was replaced, so the old release starts as it was.
89
82
  if (stopped)
90
83
  start();
91
84
  const adminBroker = await restoreBroker();
92
- report(t(result.errorKey ?? 'update.errorFailed', { version, installed: result.installed ?? '' }));
85
+ report(t(result.errorKey ?? 'update.errorFailed', {
86
+ version,
87
+ installed: result.installed ?? '',
88
+ url: cliTarballUrl(version),
89
+ }));
93
90
  if (result.summary)
94
91
  report(t('update.errorFailedDetail', { message: result.summary }));
95
92
  if (platform === 'win32' && heldByAnotherProcess(result.output)) {
@@ -99,7 +96,7 @@ export async function runUpdate(t, options = {}) {
99
96
  return { ok: false, current, updated, restarted: false, adminBroker, errorKey, summary };
100
97
  }
101
98
  const installed = result.installed ?? version;
102
- report(t(updated === 'cli' ? 'update.done' : 'relayUpdate.done', { version: installed }));
99
+ report(t('update.done', { version: installed }));
103
100
  let ok = true;
104
101
  let restarted = false;
105
102
  if (!wasRunning) {
package/dist/updater.js CHANGED
@@ -1,17 +1,25 @@
1
- // Self-update for the npm-installed CLI.
1
+ // Self-update for the installed CLI.
2
2
  //
3
- // The update path is only meaningful for a package installed from npm. A source
4
- // checkout is managed by git and a linked development copy by the developer, so
5
- // this refuses to touch either: silently running `npm install -g` over a
6
- // checkout would replace the tree someone is working in.
3
+ // Releases come from GitHub first and npm second: a GitHub Release installs the
4
+ // moment it exists, while npm can take many minutes after publishing before a
5
+ // version resolves. Both carry the same self-contained tarball, which bundles
6
+ // the relay, so one install updates both.
7
+ //
8
+ // The update path is only meaningful for a package installed with npm. A
9
+ // source checkout is managed by git and a linked development copy by the
10
+ // developer, so this refuses to touch either: silently running `npm install -g`
11
+ // over a checkout would replace the tree someone is working in.
7
12
  import fs from 'node:fs';
8
13
  import os from 'node:os';
9
14
  import path from 'node:path';
10
15
  import { spawn } from 'node:child_process';
16
+ import { createRequire } from 'node:module';
17
+ import { cliTarballUrl, GITHUB_RELEASES_URL, LATEST_CLI_TARBALL_URL, LATEST_RELEASE_MANIFEST_URL, } from 'herdr-remote-relay/protocol';
11
18
  import { PACKAGE_ROOT } from './paths.js';
12
19
  import { heldByAnotherProcess, installInPlace } from './updater/in-place.js';
13
20
  import { runNpm } from './updater/npm.js';
14
21
  const PACKAGE_NAME = 'herdr-remote';
22
+ const RELAY_PACKAGE = 'herdr-remote-relay';
15
23
  const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
16
24
  /**
17
25
  * A public mirror, tried last.
@@ -30,10 +38,20 @@ function currentVersion() {
30
38
  return '0.0.0';
31
39
  }
32
40
  }
41
+ /** The relay bundled with this herdr-remote, read from disk: an update replaces it. */
42
+ function installedRelayVersion() {
43
+ try {
44
+ const manifest = createRequire(import.meta.url).resolve(`${RELAY_PACKAGE}/package.json`);
45
+ return JSON.parse(fs.readFileSync(manifest, 'utf8')).version;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
33
51
  /**
34
52
  * How this copy got here.
35
53
  *
36
- * - `npm` installed from the registry; updating is `npm install -g`.
54
+ * - `npm` installed with npm, from a release tarball or a registry; updating is `npm install -g`.
37
55
  * - `linked` `npm link`ed into a global tree from a working copy.
38
56
  * - `source` run straight out of a checkout.
39
57
  *
@@ -167,13 +185,13 @@ async function fetchJson(url, { timeoutMs, fetchImpl }) {
167
185
  * format, which npmjs refuses on that path with a 406, so every check quietly
168
186
  * fell through to a mirror that had not synced the release yet.
169
187
  */
170
- async function askRegistry(registry, { timeoutMs, fetchImpl, packageName, }) {
188
+ async function askRegistry(registry, { timeoutMs, fetchImpl }) {
171
189
  const endpoints = [
172
190
  {
173
- url: `${registry}/-/package/${packageName}/dist-tags`,
191
+ url: `${registry}/-/package/${PACKAGE_NAME}/dist-tags`,
174
192
  read: (body) => body?.latest,
175
193
  },
176
- { url: `${registry}/${packageName}/latest`, read: (body) => body?.version },
194
+ { url: `${registry}/${PACKAGE_NAME}/latest`, read: (body) => body?.version },
177
195
  ];
178
196
  let message = null;
179
197
  for (const endpoint of endpoints) {
@@ -189,25 +207,47 @@ async function askRegistry(registry, { timeoutMs, fetchImpl, packageName, }) {
189
207
  }
190
208
  return { ok: false, message };
191
209
  }
210
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
211
+ /** The newest GitHub Release, from the manifest every release carries. */
212
+ async function askGithubReleases(manifestUrl, { timeoutMs, fetchImpl }) {
213
+ const attempt = await fetchJson(manifestUrl, { timeoutMs, fetchImpl });
214
+ if (!attempt.ok)
215
+ return attempt;
216
+ const version = attempt.body?.version;
217
+ if (typeof version === 'string' && VERSION_PATTERN.test(version)) {
218
+ return { ok: true, latest: version };
219
+ }
220
+ return { ok: false, message: 'release manifest has no version' };
221
+ }
192
222
  /**
193
223
  * Ask what the current release is.
194
224
  *
195
225
  * Never throws: an update check is a convenience, and a machine that is offline
196
226
  * or behind a proxy should still get a working settings screen.
197
227
  *
198
- * Every registry is asked at once and the newest answer wins. Taking the first
199
- * answer let a mirror a few minutes (or hours) behind report the release before
200
- * last as current. `sources` lists the registries that already carry the
201
- * newest version, preferred order first, which is where an install should come
202
- * from; `behind` lists the ones that do not, so the screen can say why.
228
+ * GitHub Releases and every registry are asked at once and the newest answer
229
+ * wins. Taking the first answer let a mirror a few minutes (or hours) behind
230
+ * report the release before last as current. `sources` lists the ones that
231
+ * already carry the newest version, GitHub first, which is where an install
232
+ * should come from; `behind` lists the ones that do not, so the screen can say
233
+ * why. GitHub is not reachable from every network, which is what the
234
+ * registries are still asked for.
203
235
  */
204
- async function checkForUpdate({ timeoutMs = 6000, fetchImpl = globalThis.fetch, registries = registryCandidates(),
205
- // Another package this one installs, and the version of it on disk: the relay.
206
- packageName = PACKAGE_NAME, current = currentVersion(), } = {}) {
207
- const attempts = await Promise.all(registries.map(async (registry) => ({
208
- registry,
209
- ...(await askRegistry(registry, { timeoutMs, fetchImpl, packageName })),
210
- })));
236
+ async function checkForUpdate({ timeoutMs = 6000, fetchImpl = globalThis.fetch, registries = registryCandidates(), releaseManifest = LATEST_RELEASE_MANIFEST_URL, current = currentVersion(), } = {}) {
237
+ const attempts = await Promise.all([
238
+ ...(releaseManifest
239
+ ? [
240
+ askGithubReleases(releaseManifest, { timeoutMs, fetchImpl }).then((answer) => ({
241
+ source: GITHUB_RELEASES_URL,
242
+ ...answer,
243
+ })),
244
+ ]
245
+ : []),
246
+ ...registries.map(async (registry) => ({
247
+ source: registry,
248
+ ...(await askRegistry(registry, { timeoutMs, fetchImpl })),
249
+ })),
250
+ ]);
211
251
  const answers = attempts.filter((attempt) => attempt.ok);
212
252
  if (answers.length === 0) {
213
253
  // The reason is carried out with the failure. "Could not reach npm
@@ -218,9 +258,8 @@ packageName = PACKAGE_NAME, current = currentVersion(), } = {}) {
218
258
  current,
219
259
  errorKey: 'update.errorNetwork',
220
260
  message: attempts
221
- .map((attempt) => `${attempt.registry}: ${'message' in attempt ? attempt.message : ''}`)
261
+ .map((attempt) => `${attempt.source}: ${'message' in attempt ? attempt.message : ''}`)
222
262
  .join('; '),
223
- triedRegistries: registries,
224
263
  };
225
264
  }
226
265
  const latest = answers
@@ -228,15 +267,15 @@ packageName = PACKAGE_NAME, current = currentVersion(), } = {}) {
228
267
  .reduce((best, version) => (compareVersions(version, best) > 0 ? version : best));
229
268
  const sources = answers
230
269
  .filter((answer) => compareVersions(answer.latest, latest) === 0)
231
- .map((answer) => answer.registry);
270
+ .map((answer) => answer.source);
232
271
  const behind = answers
233
272
  .filter((answer) => compareVersions(answer.latest, latest) < 0)
234
- .map((answer) => ({ registry: answer.registry, version: answer.latest }));
273
+ .map((answer) => ({ source: answer.source, version: answer.latest }));
235
274
  return {
236
275
  ok: true,
237
276
  current,
238
277
  latest,
239
- registry: sources[0],
278
+ source: sources[0],
240
279
  sources,
241
280
  behind,
242
281
  updateAvailable: compareVersions(latest, current) > 0,
@@ -270,7 +309,6 @@ function installedVersionOnDisk() {
270
309
  return null;
271
310
  }
272
311
  }
273
- const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
274
312
  /**
275
313
  * Install a release over this one.
276
314
  *
@@ -278,13 +316,14 @@ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
278
316
  * npm's own message; npm's diagnostics are more useful than anything that could
279
317
  * be invented here.
280
318
  *
281
- * - The exact version the check found is installed, not `@latest`, from a
282
- * registry that reported it, with `--prefer-online` so npm revalidates the
283
- * package metadata it cached before the release existed.
319
+ * - The exact version the check found is installed, not `@latest`, from the
320
+ * sources that reported it: GitHub's release tarball first, then registries,
321
+ * asked with `--prefer-online` so npm revalidates the package metadata it
322
+ * cached before the release existed.
284
323
  * - Right after a release, npm's view of the package can still lack the new
285
324
  * version although its dist-tag already points at it: npm answers ETARGET.
286
325
  * That is a wait, not a failure, so it is retried a few times, then the next
287
- * registry that carries the version is tried.
326
+ * source that carries the version is tried.
288
327
  * - On Windows, when something holds the package directory so npm cannot
289
328
  * rename it, the release is copied over it instead.
290
329
  * - Success is what is on disk afterwards, not npm's exit code.
@@ -293,30 +332,34 @@ async function performUpdate({ spawnImpl = spawn, timeoutMs = 180_000,
293
332
  // Seam for tests: the suite runs from a checkout, where the guard below is
294
333
  // correctly the only reachable outcome.
295
334
  installKindImpl = installKind,
296
- // The registries that reported `version`, preferred first. Empty means
297
- // "whatever npm is configured with", which is right when no check has run.
298
- registry = '', sources = [], version = null, attempts = 3, retryDelayMs = 15_000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), readInstalledVersion = installedVersionOnDisk, onAttempt = () => { }, platform = process.platform, inPlace = installInPlace, } = {}) {
335
+ // The sources that reported `version`, preferred first. Empty means GitHub,
336
+ // then whatever registry npm is configured with, which is right when no
337
+ // check has run.
338
+ source = '', sources = [], version = null, attempts = 3, retryDelayMs = 15_000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), readInstalledVersion = installedVersionOnDisk, onAttempt = () => { }, platform = process.platform, inPlace = installInPlace, } = {}) {
299
339
  const kind = installKindImpl();
300
340
  if (kind !== 'npm')
301
341
  return { ok: false, errorKey: `update.cannot.${kind}` };
302
342
  const target = typeof version === 'string' && VERSION_PATTERN.test(version) ? version : null;
303
- const spec = `${PACKAGE_NAME}@${target || 'latest'}`;
304
- const registries = [];
305
- for (const candidate of [registry, ...sources]) {
343
+ const candidates = [];
344
+ for (const candidate of [source, ...sources]) {
306
345
  const normalized = normalizeRegistry(candidate);
307
- if (normalized && !registries.includes(normalized))
308
- registries.push(normalized);
346
+ if (normalized && !candidates.includes(normalized))
347
+ candidates.push(normalized);
309
348
  }
310
- if (registries.length === 0)
311
- registries.push('');
349
+ // '' is npm's own configured registry.
350
+ if (candidates.length === 0)
351
+ candidates.push(GITHUB_RELEASES_URL, '');
312
352
  let last = { ok: false, output: '' };
313
353
  let notYetPublished = false;
314
- for (const source of registries) {
354
+ for (const candidate of candidates) {
355
+ const github = candidate === GITHUB_RELEASES_URL;
356
+ const args = github
357
+ ? ['install', '-g', target ? cliTarballUrl(target) : LATEST_CLI_TARBALL_URL]
358
+ : ['install', '-g', `${PACKAGE_NAME}@${target || 'latest'}`, '--prefer-online'];
359
+ if (candidate && !github)
360
+ args.push('--registry', candidate);
315
361
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
316
- onAttempt({ registry: source, attempt });
317
- const args = ['install', '-g', spec, '--prefer-online'];
318
- if (source)
319
- args.push('--registry', source);
362
+ onAttempt({ source: candidate, attempt });
320
363
  let result = await runNpm(spawnImpl, args, timeoutMs);
321
364
  if (!result.ok && platform === 'win32' && heldByAnotherProcess(result.output))
322
365
  result = await inPlace(args, (stagedArgs) => runNpm(spawnImpl, stagedArgs, timeoutMs));
@@ -343,7 +386,7 @@ registry = '', sources = [], version = null, attempts = 3, retryDelayMs = 15_000
343
386
  }
344
387
  notYetPublished = isNotYetPublished(result.output);
345
388
  if (!notYetPublished)
346
- break; // Not a wait: this registry will not do better.
389
+ break; // Not a wait: this source will not do better.
347
390
  if (attempt < attempts)
348
391
  await sleep(retryDelayMs);
349
392
  }
@@ -355,4 +398,4 @@ registry = '', sources = [], version = null, attempts = 3, retryDelayMs = 15_000
355
398
  summary: npmErrorSummary(last.output),
356
399
  };
357
400
  }
358
- export { DEFAULT_REGISTRY, MIRROR_REGISTRY, canSelfUpdate, checkForUpdate, compareVersions, currentVersion, installKind, installedVersionOnDisk, performUpdate, registryCandidates, updateChecksEnabled, };
401
+ export { DEFAULT_REGISTRY, MIRROR_REGISTRY, canSelfUpdate, checkForUpdate, compareVersions, currentVersion, installKind, installedVersionOnDisk, installedRelayVersion, performUpdate, registryCandidates, updateChecksEnabled, };
package/herdr-plugin.toml CHANGED
@@ -1,7 +1,7 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.32"
4
- min_herdr_version = "0.9.1"
3
+ version = "0.2.34"
4
+ min_herdr_version = "0.9.3"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos", "windows"]
7
7
 
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dibin666
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,76 @@
1
+ # herdr-remote-relay
2
+
3
+ *[English](README.md) · [简体中文](README.zh-CN.md)*
4
+
5
+ Standalone relay server and WebUI for [Herdr Remote](https://www.npmjs.com/package/herdr-remote).
6
+
7
+ Brokers WebSocket connections between browsers and workstation host connectors. Runtime dependency is `ws` only; requires no compilers or Herdr binaries.
8
+
9
+ > **Note**: `herdr-remote` runs a local relay automatically. Install this package only when deploying a dedicated relay server.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install -g https://github.com/dibin666/herdr-remote/releases/latest/download/herdr-remote-relay.tgz
15
+ herdr-remote-relay --public-url https://herdr.example.com --password <password>
16
+ ```
17
+
18
+ From npm instead: `npm install -g herdr-remote-relay` (it can lag a new release by a while).
19
+
20
+ Requirements: Node.js 22+, TLS reverse proxy (nginx, Caddy, Cloudflare Tunnel).
21
+
22
+ ## Docker
23
+
24
+ ```bash
25
+ docker run -d --name herdr-relay --restart unless-stopped \
26
+ -p 127.0.0.1:8787:8787 \
27
+ -v herdr-relay:/data \
28
+ -e RELAY_BIND=0.0.0.0 \
29
+ -e RELAY_PUBLIC_URL=https://herdr.example.com \
30
+ -e RELAY_PASSWORD=your-password \
31
+ -e RELAY_ADMIN_TOKEN=your-admin-token \
32
+ -e RELAY_TRUST_PROXY=1 \
33
+ -e RELAY_AUTH_STATE_FILE=/data/relay-auth.json \
34
+ node:22-alpine npx -y herdr-remote-relay
35
+ ```
36
+
37
+ ## Options
38
+
39
+ | Option | Env Var | Default | Description |
40
+ |---|---|---|---|
41
+ | `--public-url` | `RELAY_PUBLIC_URL` | `http://127.0.0.1:8787` | Public URL for browsers |
42
+ | `--password` | `RELAY_PASSWORD` | *(empty)* | Workstation join password (empty = public) |
43
+ | `--admin-token` | `RELAY_ADMIN_TOKEN` | *(empty)* | Operator token for `/admin` |
44
+ | `--bind` | `RELAY_BIND` | `127.0.0.1` | Listen address |
45
+ | `--port` | `RELAY_PORT` | `8787` | Listen port |
46
+ | `--trust-proxy` | `RELAY_TRUST_PROXY` | `0` | Trust `X-Forwarded-For` header (`1` behind proxy) |
47
+ | `--state-file` | `RELAY_AUTH_STATE_FILE` | `~/.local/state/herdr-remote-relay/relay-auth.json` | Auth state file path |
48
+ | `--allowed-origins` | `RELAY_ALLOWED_ORIGINS` | *(same-origin)* | Allowed CORS origins, comma-separated |
49
+ | `--max-clients` | `RELAY_MAX_CLIENTS_PER_HOST` | `16` | Maximum browser clients per host |
50
+ | `--max-hosts` | `RELAY_MAX_HOSTS` | `1024` | Maximum workstations on this relay |
51
+ | `--max-pending-handshakes` | `RELAY_MAX_PENDING_HANDSHAKES` | `1024` | Maximum unauthenticated WebSockets |
52
+ | `--max-buffered-bytes` | `RELAY_MAX_BUFFERED_BYTES_PER_CLIENT` | `4194304` | Maximum queued bytes per browser |
53
+ | `--host-reconnect-grace-ms` | `RELAY_HOST_RECONNECT_GRACE_MS` | `30000` | Host reconnect grace period |
54
+ | `--config` | `HERDR_RELAY_CONFIG` | *(none)* | JSON configuration file path |
55
+
56
+ Reverse proxies must pass WebSocket `Upgrade` headers and maintain reasonable idle timeouts. The relay disables WebSocket compression and enables TCP NoDelay for terminal frames. When no browser is attached, business heartbeats stop while low-frequency transport keepalive (minimal idle heartbeat and WebSocket ping/pong watchdog) remains active to prevent proxies from dropping idle connections. Deployment examples for nginx, systemd, and Docker Compose are in `deploy/`.
57
+
58
+ ## Multiple workstations and isolation
59
+
60
+ A browser can save multiple workstation pairings. The switcher in the lower-left WebUI only shows profiles saved by that browser; it never enumerates other relay hosts. `/api/status` is scoped to the workstation bound to the presented credentials, while only `RELAY_ADMIN_TOKEN` can inspect relay-wide state. `/healthz` does not disclose host or client counts.
61
+
62
+ ## Connecting a Workstation
63
+
64
+ In `herdr-remote` TUI under **Relay** tab:
65
+ - Set mode to **Self-hosted relay**
66
+ - Set Relay URL to `wss://herdr.example.com`
67
+ - Set Relay Password to matching `RELAY_PASSWORD`
68
+
69
+ Check connection status:
70
+ ```bash
71
+ curl https://herdr.example.com/healthz
72
+ ```
73
+
74
+ ## License
75
+
76
+ MIT
@@ -0,0 +1,76 @@
1
+ # herdr-remote-relay
2
+
3
+ *[English](README.md) · [简体中文](README.zh-CN.md)*
4
+
5
+ [Herdr Remote](https://www.npmjs.com/package/herdr-remote) 的独立 Relay 服务端与 WebUI。
6
+
7
+ 负责在浏览器与工作站主机连接器之间转发 WebSocket 连接。运行时仅依赖 `ws`,无需编译环境或 Herdr 二进制文件。
8
+
9
+ > **提示**:`herdr-remote` 默认在本地自动运行 relay。仅在需要部署独立中继服务器时安装本包。
10
+
11
+ ## 安装
12
+
13
+ ```bash
14
+ npm install -g https://github.com/dibin666/herdr-remote/releases/latest/download/herdr-remote-relay.tgz
15
+ herdr-remote-relay --public-url https://herdr.example.com --password <密码>
16
+ ```
17
+
18
+ 也可从 npm 安装:`npm install -g herdr-remote-relay`(新版本上线后 npm 可能要过一段时间才同步)。
19
+
20
+ 前置要求:Node.js 22+、TLS 反向代理(nginx、Caddy、Cloudflare Tunnel 等)。
21
+
22
+ ## Docker 运行
23
+
24
+ ```bash
25
+ docker run -d --name herdr-relay --restart unless-stopped \
26
+ -p 127.0.0.1:8787:8787 \
27
+ -v herdr-relay:/data \
28
+ -e RELAY_BIND=0.0.0.0 \
29
+ -e RELAY_PUBLIC_URL=https://herdr.example.com \
30
+ -e RELAY_PASSWORD=你的密码 \
31
+ -e RELAY_ADMIN_TOKEN=你的管理令牌 \
32
+ -e RELAY_TRUST_PROXY=1 \
33
+ -e RELAY_AUTH_STATE_FILE=/data/relay-auth.json \
34
+ node:22-alpine npx -y herdr-remote-relay
35
+ ```
36
+
37
+ ## 配置参数
38
+
39
+ | 参数 | 环境变量 | 默认值 | 说明 |
40
+ |---|---|---|---|
41
+ | `--public-url` | `RELAY_PUBLIC_URL` | `http://127.0.0.1:8787` | 浏览器访问的公开地址 |
42
+ | `--password` | `RELAY_PASSWORD` | *(空)* | 工作站接入密码(留空为公用 Relay) |
43
+ | `--admin-token` | `RELAY_ADMIN_TOKEN` | *(空)* | `/admin` 管理后台操作员令牌 |
44
+ | `--bind` | `RELAY_BIND` | `127.0.0.1` | 监听地址 |
45
+ | `--port` | `RELAY_PORT` | `8787` | 监听端口 |
46
+ | `--trust-proxy` | `RELAY_TRUST_PROXY` | `0` | 信任 `X-Forwarded-For` 头(反代后设为 `1`) |
47
+ | `--state-file` | `RELAY_AUTH_STATE_FILE` | `~/.local/state/herdr-remote-relay/relay-auth.json` | 认证状态文件路径 |
48
+ | `--allowed-origins` | `RELAY_ALLOWED_ORIGINS` | *(同源)* | 允许的跨域源,逗号分隔 |
49
+ | `--max-clients` | `RELAY_MAX_CLIENTS_PER_HOST` | `16` | 每台工作站最大客户端连接数 |
50
+ | `--max-hosts` | `RELAY_MAX_HOSTS` | `1024` | relay 最大工作站数 |
51
+ | `--max-pending-handshakes` | `RELAY_MAX_PENDING_HANDSHAKES` | `1024` | 最大未认证 WebSocket 握手数 |
52
+ | `--max-buffered-bytes` | `RELAY_MAX_BUFFERED_BYTES_PER_CLIENT` | `4194304` | 单个慢浏览器最大待发送缓冲 |
53
+ | `--host-reconnect-grace-ms` | `RELAY_HOST_RECONNECT_GRACE_MS` | `30000` | 工作站断线恢复宽限时间 |
54
+ | `--config` | `HERDR_RELAY_CONFIG` | *(无)* | JSON 配置文件路径 |
55
+
56
+ 反向代理必须转发 WebSocket `Upgrade` 头,并设置合理的空闲超时时间。relay 会关闭 WebSocket 压缩并启用 TCP NoDelay;没有浏览器连接时,工作站会停止业务 heartbeat,同时保留低频 transport keepalive(最小空闲 heartbeat 与 WebSocket ping/pong 探针)以防止反代断开空闲连接并维持 relay 存活。`deploy/` 目录下提供 nginx、systemd 与 Docker Compose 示例。
57
+
58
+ ## 多工作站与隔离
59
+
60
+ 同一个浏览器可以保存多个工作站配对,WebUI 左下角的实例切换器只显示本地已保存的实例,不会枚举 relay 上的其他工作站。`/api/status` 按设备令牌绑定的工作站隔离,只有 `RELAY_ADMIN_TOKEN` 才能查看 relay 全局状态;`/healthz` 不公开工作站和客户端数量。
61
+
62
+ ## 工作站连接
63
+
64
+ 在工作站的 `herdr-remote` TUI 中进入 **Relay** 页面:
65
+ - 访问方式选择 **自建 relay**
66
+ - Relay 地址填写 `wss://herdr.example.com`
67
+ - Relay 密码填写对应的 `RELAY_PASSWORD`
68
+
69
+ 验证连接状态:
70
+ ```bash
71
+ curl https://herdr.example.com/healthz
72
+ ```
73
+
74
+ ## 开源协议
75
+
76
+ MIT