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.
- package/README.md +3 -1
- package/README.zh-CN.md +2 -1
- package/dist/connector/agent-watch.js +16 -3
- package/dist/connector/herdr-prefix-keys.js +28 -0
- package/dist/connector/host-connector.js +6 -20
- package/dist/connector/host-hello.js +32 -0
- package/dist/herdr-api.js +7 -1
- package/dist/herdr-command.js +7 -2
- package/dist/i18n/en.js +5 -18
- package/dist/i18n/zh.js +5 -18
- package/dist/pty-session.js +27 -2
- package/dist/socket-discovery.js +16 -6
- package/dist/terminal-font/formats.js +26 -6
- package/dist/terminal-font/terminals.js +19 -3
- package/dist/tui/App.js +3 -25
- package/dist/tui/api.js +3 -3
- package/dist/tui/screens/About.js +18 -27
- package/dist/update-command.js +16 -19
- package/dist/updater.js +91 -48
- package/herdr-plugin.toml +2 -2
- package/node_modules/herdr-remote-relay/LICENSE +21 -0
- package/node_modules/herdr-remote-relay/README.md +76 -0
- package/node_modules/herdr-remote-relay/README.zh-CN.md +76 -0
- package/node_modules/herdr-remote-relay/bin/herdr-remote-relay.js +116 -0
- package/node_modules/herdr-remote-relay/config.example.json +18 -0
- package/node_modules/herdr-remote-relay/deploy/cloudflared/config.yml.example +9 -0
- package/node_modules/herdr-remote-relay/deploy/docker/compose.yaml +23 -0
- package/node_modules/herdr-remote-relay/deploy/nginx/herdr-remote-relay.conf.example +49 -0
- package/node_modules/herdr-remote-relay/deploy/systemd/herdr-remote-relay.service +41 -0
- package/node_modules/herdr-remote-relay/deploy/systemd/relay.env.example +25 -0
- package/node_modules/herdr-remote-relay/dist/auth-store.d.ts +114 -0
- package/node_modules/herdr-remote-relay/dist/auth-store.js +289 -0
- package/node_modules/herdr-remote-relay/dist/metrics.d.ts +76 -0
- package/node_modules/herdr-remote-relay/dist/metrics.js +249 -0
- package/node_modules/herdr-remote-relay/dist/protocol/frames.d.ts +27 -0
- package/node_modules/herdr-remote-relay/dist/protocol/frames.js +110 -0
- package/node_modules/herdr-remote-relay/dist/protocol/herdr-keys.d.ts +8 -0
- package/node_modules/herdr-remote-relay/dist/protocol/herdr-keys.js +34 -0
- package/node_modules/herdr-remote-relay/dist/protocol/http.d.ts +165 -0
- package/node_modules/herdr-remote-relay/dist/protocol/http.js +3 -0
- package/node_modules/herdr-remote-relay/dist/protocol/index.d.ts +7 -0
- package/node_modules/herdr-remote-relay/dist/protocol/index.js +9 -0
- package/node_modules/herdr-remote-relay/dist/protocol/messages.d.ts +362 -0
- package/node_modules/herdr-remote-relay/dist/protocol/messages.js +20 -0
- package/node_modules/herdr-remote-relay/dist/protocol/paste.d.ts +14 -0
- package/node_modules/herdr-remote-relay/dist/protocol/paste.js +50 -0
- package/node_modules/herdr-remote-relay/dist/protocol/release.d.ts +8 -0
- package/node_modules/herdr-remote-relay/dist/protocol/release.js +17 -0
- package/node_modules/herdr-remote-relay/dist/protocol/terminal.d.ts +79 -0
- package/node_modules/herdr-remote-relay/dist/protocol/terminal.js +150 -0
- package/node_modules/herdr-remote-relay/dist/relay-config.d.ts +74 -0
- package/node_modules/herdr-remote-relay/dist/relay-config.js +316 -0
- package/node_modules/herdr-remote-relay/dist/relay-server.d.ts +64 -0
- package/node_modules/herdr-remote-relay/dist/relay-server.js +194 -0
- package/node_modules/herdr-remote-relay/dist/scroll-input.d.ts +17 -0
- package/node_modules/herdr-remote-relay/dist/scroll-input.js +124 -0
- package/node_modules/herdr-remote-relay/dist/server/client-channel.d.ts +3 -0
- package/node_modules/herdr-remote-relay/dist/server/client-channel.js +396 -0
- package/node_modules/herdr-remote-relay/dist/server/font-proxy.d.ts +19 -0
- package/node_modules/herdr-remote-relay/dist/server/font-proxy.js +155 -0
- package/node_modules/herdr-remote-relay/dist/server/host-broadcasts.d.ts +27 -0
- package/node_modules/herdr-remote-relay/dist/server/host-broadcasts.js +83 -0
- package/node_modules/herdr-remote-relay/dist/server/host-channel.d.ts +3 -0
- package/node_modules/herdr-remote-relay/dist/server/host-channel.js +271 -0
- package/node_modules/herdr-remote-relay/dist/server/http-api.d.ts +3 -0
- package/node_modules/herdr-remote-relay/dist/server/http-api.js +214 -0
- package/node_modules/herdr-remote-relay/dist/server/maintenance.d.ts +5 -0
- package/node_modules/herdr-remote-relay/dist/server/maintenance.js +64 -0
- package/node_modules/herdr-remote-relay/dist/server/requests.d.ts +34 -0
- package/node_modules/herdr-remote-relay/dist/server/requests.js +142 -0
- package/node_modules/herdr-remote-relay/dist/server/sessions.d.ts +36 -0
- package/node_modules/herdr-remote-relay/dist/server/sessions.js +230 -0
- package/node_modules/herdr-remote-relay/dist/server/sockets.d.ts +12 -0
- package/node_modules/herdr-remote-relay/dist/server/sockets.js +64 -0
- package/node_modules/herdr-remote-relay/dist/server/status.d.ts +11 -0
- package/node_modules/herdr-remote-relay/dist/server/status.js +99 -0
- package/node_modules/herdr-remote-relay/dist/server/transport.d.ts +16 -0
- package/node_modules/herdr-remote-relay/dist/server/transport.js +110 -0
- package/node_modules/herdr-remote-relay/dist/server/types.d.ts +131 -0
- package/node_modules/herdr-remote-relay/dist/server/types.js +3 -0
- package/node_modules/herdr-remote-relay/dist/server/version.d.ts +2 -0
- package/node_modules/herdr-remote-relay/dist/server/version.js +5 -0
- package/node_modules/herdr-remote-relay/dist/state.d.ts +6 -0
- package/node_modules/herdr-remote-relay/dist/state.js +47 -0
- package/node_modules/herdr-remote-relay/package.json +76 -0
- package/node_modules/herdr-remote-relay/web/dist/assets/index-BeveQ9Di.js +61 -0
- package/node_modules/herdr-remote-relay/web/dist/assets/index-XYYRsgSb.css +32 -0
- package/node_modules/herdr-remote-relay/web/dist/assets/vendor-react-CYSfZuHu.js +17 -0
- package/node_modules/herdr-remote-relay/web/dist/assets/vendor-xterm-BhCxil_4.js +9 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/SymbolsNerdFontMono-Regular.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/OFL.txt +93 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/OFL.txt +93 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-ext-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-ext-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/OFL.txt +93 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/OFL.txt +93 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/OFL.txt +93 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/index.html +36 -0
- package/package.json +6 -3
- package/dist/relay-updater.js +0 -81
- package/dist/tui/screens/useRelayUpdate.js +0 -122
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The relay: one HTTP server that answers the API, serves the web app and
|
|
2
|
+
// upgrades /ws/host and /ws/client. The work itself lives in ./server/.
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { URL } from 'node:url';
|
|
6
|
+
import { WebSocketServer } from 'ws';
|
|
7
|
+
import { AuthStore } from './auth-store.js';
|
|
8
|
+
import { RelayMetrics } from './metrics.js';
|
|
9
|
+
import { PROTOCOL_VERSION, WS_CLIENT_PATH, WS_HOST_PATH } from './protocol/index.js';
|
|
10
|
+
import { DEFAULTS, defaultStateDir, loadRelayConfig } from './relay-config.js';
|
|
11
|
+
import { handleClientConnection } from './server/client-channel.js';
|
|
12
|
+
import { handleHostConnection } from './server/host-channel.js';
|
|
13
|
+
import { handleHttp } from './server/http-api.js';
|
|
14
|
+
import { heartbeat, sweep } from './server/maintenance.js';
|
|
15
|
+
import { isAllowedOrigin } from './server/requests.js';
|
|
16
|
+
import { detachClient, detachHost } from './server/sessions.js';
|
|
17
|
+
import { VERSION } from './server/version.js';
|
|
18
|
+
import { ensureDir } from './state.js';
|
|
19
|
+
class RelayServer {
|
|
20
|
+
config;
|
|
21
|
+
relayMode;
|
|
22
|
+
hosts = new Map();
|
|
23
|
+
clients = new Map();
|
|
24
|
+
streams = new Map();
|
|
25
|
+
pairAttempts = new Map();
|
|
26
|
+
clientHandshakeAttempts = new Map();
|
|
27
|
+
hostHandshakeAttempts = new Map();
|
|
28
|
+
/** Sockets that are upgraded but have not finished their hello. */
|
|
29
|
+
pendingHandshakes = new Set();
|
|
30
|
+
startedAt = Date.now();
|
|
31
|
+
metrics;
|
|
32
|
+
stateFile;
|
|
33
|
+
password;
|
|
34
|
+
adminToken;
|
|
35
|
+
trustProxy;
|
|
36
|
+
devLatencyMs;
|
|
37
|
+
devDelayMs;
|
|
38
|
+
sendQueues = new WeakMap();
|
|
39
|
+
activeDelayTimers = new Set();
|
|
40
|
+
auth;
|
|
41
|
+
server;
|
|
42
|
+
wss;
|
|
43
|
+
heartbeatTimer = null;
|
|
44
|
+
cleanupTimer = null;
|
|
45
|
+
constructor(config = loadRelayConfig().config, options = {}) {
|
|
46
|
+
this.config = {
|
|
47
|
+
relay: { ...DEFAULTS.relay, ...config.relay },
|
|
48
|
+
auth: { ...DEFAULTS.auth, ...config.auth },
|
|
49
|
+
cleanup: { ...DEFAULTS.cleanup, ...config.cleanup },
|
|
50
|
+
};
|
|
51
|
+
const { relay, auth } = this.config;
|
|
52
|
+
this.relayMode = relay.mode === 'local' ? 'local' : 'remote';
|
|
53
|
+
this.metrics =
|
|
54
|
+
options.metrics || new RelayMetrics({ version: VERSION, protocolVersion: PROTOCOL_VERSION });
|
|
55
|
+
this.stateFile =
|
|
56
|
+
options.stateFile || auth.stateFile || path.join(defaultStateDir(), 'relay-auth.json');
|
|
57
|
+
this.password = options.password ?? auth.password ?? null;
|
|
58
|
+
this.adminToken = options.adminToken ?? auth.adminToken ?? null;
|
|
59
|
+
this.trustProxy = Boolean(options.trustProxy ?? relay.trustProxy);
|
|
60
|
+
// Development-only artificial latency switch for local responsiveness profiling.
|
|
61
|
+
// When unset or 0, this incurs zero overhead and avoids entering the delayed path.
|
|
62
|
+
// RELAY_DEV_LATENCY_MS specifies round-trip delay, so each one-way leg
|
|
63
|
+
// (host -> browser and browser -> host) is delayed by half.
|
|
64
|
+
const devLatencyRaw = options.devLatencyMs ?? relay.devLatencyMs ?? process.env.RELAY_DEV_LATENCY_MS;
|
|
65
|
+
this.devLatencyMs = devLatencyRaw
|
|
66
|
+
? Math.max(0, Number.parseInt(String(devLatencyRaw), 10) || 0)
|
|
67
|
+
: 0;
|
|
68
|
+
this.devDelayMs = this.devLatencyMs > 0 ? Math.round(this.devLatencyMs / 2) : 0;
|
|
69
|
+
this.auth =
|
|
70
|
+
options.auth ||
|
|
71
|
+
new AuthStore({
|
|
72
|
+
stateFile: this.stateFile,
|
|
73
|
+
pairingTtlMs: auth.pairingTtlMs,
|
|
74
|
+
deviceTtlMs: auth.deviceTtlMs,
|
|
75
|
+
maxDevices: auth.maxDevices,
|
|
76
|
+
password: this.password,
|
|
77
|
+
});
|
|
78
|
+
this.server = http.createServer((req, res) => handleHttp(this, req, res));
|
|
79
|
+
this.wss = new WebSocketServer({
|
|
80
|
+
noServer: true,
|
|
81
|
+
clientTracking: false,
|
|
82
|
+
maxPayload: relay.maxPayloadBytes,
|
|
83
|
+
// Frames below 1024 bytes bypass compression completely, ensuring single
|
|
84
|
+
// keystrokes and small echoes incur zero CPU and zero buffering delay.
|
|
85
|
+
// Keeping context across messages (NoContextTakeover=false) maximizes
|
|
86
|
+
// compression ratios on highly repetitive full-screen ratatui/ANSI redraws;
|
|
87
|
+
// level 3 provides low CPU cost and low latency over peak compression.
|
|
88
|
+
perMessageDeflate: {
|
|
89
|
+
threshold: 1024,
|
|
90
|
+
zlibDeflateOptions: { level: 3 },
|
|
91
|
+
serverNoContextTakeover: false,
|
|
92
|
+
clientNoContextTakeover: false,
|
|
93
|
+
concurrencyLimit: 10,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
this.server.on('upgrade', (req, socket, head) => this.handleUpgrade(req, socket, head));
|
|
97
|
+
}
|
|
98
|
+
listen(port = this.config.relay.port, host = this.config.relay.host) {
|
|
99
|
+
ensureDir(path.dirname(this.stateFile));
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const onError = (error) => {
|
|
102
|
+
this.server.off('listening', onListening);
|
|
103
|
+
reject(error);
|
|
104
|
+
};
|
|
105
|
+
const onListening = () => {
|
|
106
|
+
this.server.off('error', onError);
|
|
107
|
+
this.heartbeatTimer = setInterval(() => heartbeat(this), this.config.cleanup.heartbeatIntervalMs);
|
|
108
|
+
this.cleanupTimer = setInterval(() => sweep(this), this.config.cleanup.intervalMs);
|
|
109
|
+
resolve(this.address());
|
|
110
|
+
};
|
|
111
|
+
this.server.once('error', onError);
|
|
112
|
+
this.server.once('listening', onListening);
|
|
113
|
+
this.server.listen(port, host);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
address() {
|
|
117
|
+
const address = this.server.address();
|
|
118
|
+
if (!address)
|
|
119
|
+
return null;
|
|
120
|
+
return typeof address === 'string'
|
|
121
|
+
? { path: address }
|
|
122
|
+
: { host: address.address, port: address.port };
|
|
123
|
+
}
|
|
124
|
+
async close() {
|
|
125
|
+
if (this.heartbeatTimer)
|
|
126
|
+
clearInterval(this.heartbeatTimer);
|
|
127
|
+
if (this.cleanupTimer)
|
|
128
|
+
clearInterval(this.cleanupTimer);
|
|
129
|
+
this.heartbeatTimer = null;
|
|
130
|
+
this.cleanupTimer = null;
|
|
131
|
+
for (const timer of this.activeDelayTimers)
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
this.activeDelayTimers.clear();
|
|
134
|
+
for (const client of [...this.clients.values()])
|
|
135
|
+
detachClient(this, client, { notify: false });
|
|
136
|
+
for (const host of [...this.hosts.values()])
|
|
137
|
+
detachHost(this, host, { notify: false });
|
|
138
|
+
this.streams.clear();
|
|
139
|
+
this.pairAttempts.clear();
|
|
140
|
+
this.clientHandshakeAttempts.clear();
|
|
141
|
+
this.hostHandshakeAttempts.clear();
|
|
142
|
+
this.pendingHandshakes.clear();
|
|
143
|
+
this.metrics.close();
|
|
144
|
+
await new Promise((resolve) => {
|
|
145
|
+
if (!this.server.listening)
|
|
146
|
+
return resolve();
|
|
147
|
+
this.server.close(() => resolve());
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
handleUpgrade(req, socket, head) {
|
|
151
|
+
const pathname = (() => {
|
|
152
|
+
try {
|
|
153
|
+
return new URL(req.url || '', 'http://localhost').pathname;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return '';
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
if (![WS_HOST_PATH, WS_CLIENT_PATH].includes(pathname) ||
|
|
160
|
+
!isAllowedOrigin(this, req.headers.origin, req)) {
|
|
161
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
162
|
+
socket.destroy();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (this.pendingHandshakes.size >= this.config.relay.maxPendingHandshakes) {
|
|
166
|
+
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
|
167
|
+
socket.destroy();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// Small ANSI/input frames should not wait behind Nagle's timer. This is
|
|
171
|
+
// safe for both plain HTTP and TLS sockets and also benefits a relay behind
|
|
172
|
+
// a reverse proxy by keeping the relay leg immediately writable.
|
|
173
|
+
try {
|
|
174
|
+
const tcp = socket;
|
|
175
|
+
tcp.setNoDelay(true);
|
|
176
|
+
tcp.setKeepAlive?.(true, this.config.cleanup.heartbeatIntervalMs);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Not a TCP socket (tests, proxies); these are only latency and liveness tweaks.
|
|
180
|
+
}
|
|
181
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
182
|
+
this.pendingHandshakes.add(ws);
|
|
183
|
+
ws.once('close', () => this.finishHandshake(ws));
|
|
184
|
+
if (pathname === WS_HOST_PATH)
|
|
185
|
+
handleHostConnection(this, ws, req);
|
|
186
|
+
else
|
|
187
|
+
handleClientConnection(this, ws, req);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
finishHandshake(ws) {
|
|
191
|
+
this.pendingHandshakes.delete(ws);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
export { RelayServer, PROTOCOL_VERSION, VERSION };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wheel-report recognition for read-only clients.
|
|
3
|
+
*
|
|
4
|
+
* A viewer holds no control lease and may not type into the terminal. It may
|
|
5
|
+
* still scroll, for the same reason `resize` does not require the lease: every
|
|
6
|
+
* client drives its *own* PTY stream (`session_start` is emitted per client
|
|
7
|
+
* with `streamId: client.id`), so a wheel report moves only that viewer's own
|
|
8
|
+
* screen and cannot disturb the controller.
|
|
9
|
+
*
|
|
10
|
+
* This module is the trust boundary for that allowance, so the match is exact
|
|
11
|
+
* rather than heuristic. A frame is forwarded only when it consists entirely
|
|
12
|
+
* of wheel reports in the encodings a terminal emits; a single stray byte —
|
|
13
|
+
* a keystroke, a click, a drag — rejects the whole frame.
|
|
14
|
+
*/
|
|
15
|
+
/** Whether every byte of `payload`, a raw client input frame, belongs to a wheel report. */
|
|
16
|
+
declare function isWheelOnlyInput(payload: Uint8Array | ArrayBuffer | null | undefined): boolean;
|
|
17
|
+
export { isWheelOnlyInput };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wheel-report recognition for read-only clients.
|
|
3
|
+
*
|
|
4
|
+
* A viewer holds no control lease and may not type into the terminal. It may
|
|
5
|
+
* still scroll, for the same reason `resize` does not require the lease: every
|
|
6
|
+
* client drives its *own* PTY stream (`session_start` is emitted per client
|
|
7
|
+
* with `streamId: client.id`), so a wheel report moves only that viewer's own
|
|
8
|
+
* screen and cannot disturb the controller.
|
|
9
|
+
*
|
|
10
|
+
* This module is the trust boundary for that allowance, so the match is exact
|
|
11
|
+
* rather than heuristic. A frame is forwarded only when it consists entirely
|
|
12
|
+
* of wheel reports in the encodings a terminal emits; a single stray byte —
|
|
13
|
+
* a keystroke, a click, a drag — rejects the whole frame.
|
|
14
|
+
*/
|
|
15
|
+
const ESC = 0x1b;
|
|
16
|
+
const LEFT_BRACKET = 0x5b; // [
|
|
17
|
+
const LESS_THAN = 0x3c; // <
|
|
18
|
+
const SEMICOLON = 0x3b; // ;
|
|
19
|
+
const UPPER_M = 0x4d; // M
|
|
20
|
+
const ZERO = 0x30;
|
|
21
|
+
const NINE = 0x39;
|
|
22
|
+
/**
|
|
23
|
+
* The wheel sets bit 64 of the event code; bit 1 is up/down and bits 4/8/16
|
|
24
|
+
* are shift/alt/ctrl. Every other bit belongs to something that is not a
|
|
25
|
+
* wheel: bit 32 marks motion, bits 2 and 128 select other buttons.
|
|
26
|
+
*/
|
|
27
|
+
const WHEEL_BASE = 64;
|
|
28
|
+
const WHEEL_CODE_MASK = ~(1 | 4 | 8 | 16);
|
|
29
|
+
/** Single-byte (X10) parameters are biased by 32 and cannot exceed one byte. */
|
|
30
|
+
const X10_PARAM_BIAS = 32;
|
|
31
|
+
const X10_PARAM_MAX = 255;
|
|
32
|
+
function isWheelCode(code) {
|
|
33
|
+
return Number.isInteger(code) && (code & WHEEL_CODE_MASK) === WHEEL_BASE;
|
|
34
|
+
}
|
|
35
|
+
function isDigit(byte) {
|
|
36
|
+
return byte >= ZERO && byte <= NINE;
|
|
37
|
+
}
|
|
38
|
+
/** Reads one decimal parameter. */
|
|
39
|
+
function readNumber(bytes, start) {
|
|
40
|
+
let index = start;
|
|
41
|
+
let value = 0;
|
|
42
|
+
while (index < bytes.length && isDigit(bytes[index])) {
|
|
43
|
+
value = value * 10 + (bytes[index] - ZERO);
|
|
44
|
+
// A parameter long enough to overflow is not something a terminal emits.
|
|
45
|
+
if (value > 0xffff)
|
|
46
|
+
return null;
|
|
47
|
+
index += 1;
|
|
48
|
+
}
|
|
49
|
+
if (index === start)
|
|
50
|
+
return null;
|
|
51
|
+
return { value, next: index };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Matches `ESC [ < Pb ; Px ; Py M`, the SGR and SGR-pixels encodings.
|
|
55
|
+
* @returns offset after the report, or -1
|
|
56
|
+
*/
|
|
57
|
+
function matchSgrWheel(bytes, start) {
|
|
58
|
+
if (bytes[start] !== ESC || bytes[start + 1] !== LEFT_BRACKET || bytes[start + 2] !== LESS_THAN) {
|
|
59
|
+
return -1;
|
|
60
|
+
}
|
|
61
|
+
const code = readNumber(bytes, start + 3);
|
|
62
|
+
if (!code || !isWheelCode(code.value) || bytes[code.next] !== SEMICOLON)
|
|
63
|
+
return -1;
|
|
64
|
+
const first = readNumber(bytes, code.next + 1);
|
|
65
|
+
if (!first || bytes[first.next] !== SEMICOLON)
|
|
66
|
+
return -1;
|
|
67
|
+
const second = readNumber(bytes, first.next + 1);
|
|
68
|
+
if (!second)
|
|
69
|
+
return -1;
|
|
70
|
+
// A wheel is never released, so the terminator is always `M`. Accepting `m`
|
|
71
|
+
// here would admit button releases, which are not scrolling.
|
|
72
|
+
if (bytes[second.next] !== UPPER_M)
|
|
73
|
+
return -1;
|
|
74
|
+
return second.next + 1;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Matches `ESC [ M Pb Px Py`, the default single-byte encoding.
|
|
78
|
+
* @returns offset after the report, or -1
|
|
79
|
+
*/
|
|
80
|
+
function matchX10Wheel(bytes, start) {
|
|
81
|
+
if (bytes[start] !== ESC || bytes[start + 1] !== LEFT_BRACKET || bytes[start + 2] !== UPPER_M) {
|
|
82
|
+
return -1;
|
|
83
|
+
}
|
|
84
|
+
if (start + 6 > bytes.length)
|
|
85
|
+
return -1;
|
|
86
|
+
const code = bytes[start + 3] - X10_PARAM_BIAS;
|
|
87
|
+
const column = bytes[start + 4];
|
|
88
|
+
const row = bytes[start + 5];
|
|
89
|
+
if (!isWheelCode(code))
|
|
90
|
+
return -1;
|
|
91
|
+
if (column < X10_PARAM_BIAS || column > X10_PARAM_MAX)
|
|
92
|
+
return -1;
|
|
93
|
+
if (row < X10_PARAM_BIAS || row > X10_PARAM_MAX)
|
|
94
|
+
return -1;
|
|
95
|
+
return start + 6;
|
|
96
|
+
}
|
|
97
|
+
/** Whether every byte of `payload`, a raw client input frame, belongs to a wheel report. */
|
|
98
|
+
function isWheelOnlyInput(payload) {
|
|
99
|
+
if (!payload)
|
|
100
|
+
return false;
|
|
101
|
+
const bytes = payload instanceof Uint8Array
|
|
102
|
+
? payload
|
|
103
|
+
: payload instanceof ArrayBuffer
|
|
104
|
+
? new Uint8Array(payload)
|
|
105
|
+
: null;
|
|
106
|
+
if (!bytes || bytes.length === 0)
|
|
107
|
+
return false;
|
|
108
|
+
let offset = 0;
|
|
109
|
+
while (offset < bytes.length) {
|
|
110
|
+
const sgr = matchSgrWheel(bytes, offset);
|
|
111
|
+
if (sgr > offset) {
|
|
112
|
+
offset = sgr;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const x10 = matchX10Wheel(bytes, offset);
|
|
116
|
+
if (x10 > offset) {
|
|
117
|
+
offset = x10;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
export { isWheelOnlyInput };
|