herdr-remote-relay 0.3.11 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/herdr-remote-relay.js +20 -20
- package/dist/auth-store.d.ts +114 -0
- package/dist/auth-store.js +276 -0
- package/dist/metrics.d.ts +76 -0
- package/dist/metrics.js +249 -0
- package/dist/protocol/frames.d.ts +27 -0
- package/dist/protocol/frames.js +110 -0
- package/dist/protocol/http.d.ts +161 -0
- package/dist/protocol/http.js +3 -0
- package/dist/protocol/index.d.ts +5 -0
- package/dist/protocol/index.js +7 -0
- package/dist/protocol/messages.d.ts +321 -0
- package/dist/protocol/messages.js +18 -0
- package/dist/protocol/paste.d.ts +14 -0
- package/dist/protocol/paste.js +50 -0
- package/dist/protocol/terminal.d.ts +79 -0
- package/dist/protocol/terminal.js +150 -0
- package/dist/relay-config.d.ts +74 -0
- package/dist/relay-config.js +316 -0
- package/dist/relay-server.d.ts +64 -0
- package/dist/relay-server.js +194 -0
- package/dist/scroll-input.d.ts +17 -0
- package/dist/scroll-input.js +124 -0
- package/dist/server/client-channel.d.ts +3 -0
- package/dist/server/client-channel.js +365 -0
- package/dist/server/font-proxy.d.ts +19 -0
- package/dist/server/font-proxy.js +155 -0
- package/dist/server/host-broadcasts.d.ts +25 -0
- package/dist/server/host-broadcasts.js +79 -0
- package/dist/server/host-channel.d.ts +3 -0
- package/dist/server/host-channel.js +266 -0
- package/dist/server/http-api.d.ts +3 -0
- package/dist/server/http-api.js +214 -0
- package/dist/server/maintenance.d.ts +5 -0
- package/dist/server/maintenance.js +64 -0
- package/dist/server/requests.d.ts +34 -0
- package/dist/server/requests.js +142 -0
- package/dist/server/sessions.d.ts +36 -0
- package/dist/server/sessions.js +225 -0
- package/dist/server/sockets.d.ts +12 -0
- package/dist/server/sockets.js +64 -0
- package/dist/server/status.d.ts +11 -0
- package/dist/server/status.js +95 -0
- package/dist/server/transport.d.ts +16 -0
- package/dist/server/transport.js +110 -0
- package/dist/server/types.d.ts +123 -0
- package/dist/server/types.js +3 -0
- package/dist/server/version.d.ts +2 -0
- package/dist/server/version.js +5 -0
- package/dist/state.d.ts +6 -0
- package/dist/state.js +47 -0
- package/package.json +32 -8
- package/web/dist/assets/index-B-5zRmL_.js +61 -0
- package/web/dist/assets/index-DKt8KLog.css +32 -0
- package/web/dist/assets/{vendor-xterm-Bbu8R_5E.js → vendor-xterm-BhCxil_4.js} +2 -2
- package/web/dist/index.html +3 -3
- package/src/auth-store.js +0 -256
- package/src/metrics.js +0 -241
- package/src/relay-config.js +0 -312
- package/src/relay-server.js +0 -1799
- package/src/scroll-input.js +0 -139
- package/src/state.js +0 -47
- package/src/stream-frame.js +0 -238
- package/web/dist/assets/index-2ut7QeyF.css +0 -32
- package/web/dist/assets/index-cIIMc3UE.js +0 -61
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Sending to windows and hosts: terminal output with back-pressure, JSON
|
|
2
|
+
// broadcasts, and the development-only latency queue.
|
|
3
|
+
import { detachClient } from './sessions.js';
|
|
4
|
+
import { isOpen, jsonSend } from './sockets.js';
|
|
5
|
+
/** Notify the host whether any browser currently needs business telemetry. */
|
|
6
|
+
export function notifyHostClientCount(host) {
|
|
7
|
+
if (!host || !isOpen(host.ws))
|
|
8
|
+
return;
|
|
9
|
+
jsonSend(host.ws, { type: 'client_count', clientCount: host.clients.size });
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Forward output without allowing one slow browser to grow an unbounded ws
|
|
13
|
+
* queue. Closing only that browser preserves low latency for the other views.
|
|
14
|
+
*/
|
|
15
|
+
export function sendClientBinary(relay, host, client, payload) {
|
|
16
|
+
if (!client || !isOpen(client.ws))
|
|
17
|
+
return false;
|
|
18
|
+
const limit = relay.config.relay.maxBufferedBytesPerClient;
|
|
19
|
+
const buffered = Number(client.ws.bufferedAmount) || 0;
|
|
20
|
+
if (buffered + payload.length > limit) {
|
|
21
|
+
relay.metrics.recordCleanup('slowClientsDropped');
|
|
22
|
+
detachClient(relay, client, { notify: true, reason: 'slow_client', closeCode: 1013 });
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
let sent = false;
|
|
26
|
+
const doSend = () => {
|
|
27
|
+
if (!isOpen(client.ws))
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
client.ws.send(payload);
|
|
31
|
+
client.bytesSent += payload.length;
|
|
32
|
+
relay.metrics.recordOut(payload.length, host.id);
|
|
33
|
+
sent = true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
detachClient(relay, client, { notify: false, reason: 'client_send_failed', closeCode: 1011 });
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
if (relay.devLatencyMs > 0)
|
|
40
|
+
enqueueDelayedSend(relay, client.ws, doSend);
|
|
41
|
+
else
|
|
42
|
+
doSend();
|
|
43
|
+
return relay.devLatencyMs > 0 ? true : sent;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Queue artificial delay per-socket rather than using naked setTimeout calls.
|
|
47
|
+
* Concurrent timers experience event loop jitter that can deliver frames out of order;
|
|
48
|
+
* a single FIFO queue per socket guarantees strict in-order delivery of terminal frames.
|
|
49
|
+
*/
|
|
50
|
+
export function enqueueDelayedSend(relay, socket, task) {
|
|
51
|
+
let queue = relay.sendQueues.get(socket);
|
|
52
|
+
if (!queue) {
|
|
53
|
+
const created = { items: [], timer: null };
|
|
54
|
+
queue = created;
|
|
55
|
+
relay.sendQueues.set(socket, created);
|
|
56
|
+
socket.once('close', () => {
|
|
57
|
+
if (created.timer) {
|
|
58
|
+
clearTimeout(created.timer);
|
|
59
|
+
relay.activeDelayTimers.delete(created.timer);
|
|
60
|
+
created.timer = null;
|
|
61
|
+
}
|
|
62
|
+
created.items = [];
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const sendAt = Date.now() + relay.devDelayMs;
|
|
66
|
+
queue.items.push({ sendAt, task });
|
|
67
|
+
if (!queue.timer) {
|
|
68
|
+
const pending = queue;
|
|
69
|
+
const timer = setTimeout(() => flushSendQueue(relay, socket, pending), relay.devDelayMs);
|
|
70
|
+
queue.timer = timer;
|
|
71
|
+
relay.activeDelayTimers.add(timer);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Drain ready frames in FIFO order up to the current timestamp, then schedule the
|
|
76
|
+
* single next timer if items remain. Ensures only one timer runs per socket at a time.
|
|
77
|
+
*/
|
|
78
|
+
function flushSendQueue(relay, socket, queue) {
|
|
79
|
+
if (queue.timer) {
|
|
80
|
+
relay.activeDelayTimers.delete(queue.timer);
|
|
81
|
+
queue.timer = null;
|
|
82
|
+
}
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
while (queue.items.length > 0 && queue.items[0].sendAt <= now) {
|
|
85
|
+
const item = queue.items.shift();
|
|
86
|
+
try {
|
|
87
|
+
item?.task();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// A send to a socket that closed while queued is dropped like any other.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (queue.items.length > 0 && !queue.timer) {
|
|
94
|
+
const nextDelay = Math.max(0, queue.items[0].sendAt - Date.now());
|
|
95
|
+
const timer = setTimeout(() => flushSendQueue(relay, socket, queue), nextDelay);
|
|
96
|
+
queue.timer = timer;
|
|
97
|
+
relay.activeDelayTimers.add(timer);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Send one JSON message to every browser attached to `host`. */
|
|
101
|
+
export function broadcastToClients(relay, host, build) {
|
|
102
|
+
for (const clientId of [...host.clients]) {
|
|
103
|
+
const client = relay.clients.get(clientId);
|
|
104
|
+
if (!client)
|
|
105
|
+
continue;
|
|
106
|
+
const payload = build(client);
|
|
107
|
+
if (payload)
|
|
108
|
+
jsonSend(client.ws, payload);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { WebSocket } from 'ws';
|
|
2
|
+
import type { AuthStore } from '../auth-store.js';
|
|
3
|
+
import type { RelayMetrics } from '../metrics.js';
|
|
4
|
+
import type { ClientRole, HostPtySummary, ServerAgentStatusMessage, ServerUpdateStatusMessage } from '../protocol/messages.js';
|
|
5
|
+
import type { HostTerminalFont, HostTerminalPalette } from '../protocol/terminal.js';
|
|
6
|
+
import type { RelayConfig } from '../relay-config.js';
|
|
7
|
+
/** A WebSocket plus the liveness flag the heartbeat keeps on it. */
|
|
8
|
+
export type RelaySocket = WebSocket & {
|
|
9
|
+
isAlive?: boolean;
|
|
10
|
+
};
|
|
11
|
+
/** One browser window's PTY stream on its host. */
|
|
12
|
+
interface ClientSession {
|
|
13
|
+
streamId: string;
|
|
14
|
+
/** The v2 frame index, when the host negotiated v2 frames and one was free. */
|
|
15
|
+
streamIndex: number | null;
|
|
16
|
+
cols: number;
|
|
17
|
+
rows: number;
|
|
18
|
+
ready: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** A connected workstation. */
|
|
21
|
+
export interface RelayHost {
|
|
22
|
+
id: string;
|
|
23
|
+
ws: RelaySocket | null;
|
|
24
|
+
hostname: string;
|
|
25
|
+
platform: string;
|
|
26
|
+
arch: string;
|
|
27
|
+
connectedAt: string;
|
|
28
|
+
connectedAtMs: number;
|
|
29
|
+
terminalPalette: HostTerminalPalette | null;
|
|
30
|
+
terminalFont: HostTerminalFont | null;
|
|
31
|
+
/** The latest agent summary, replayed to a window that joins late. */
|
|
32
|
+
agentStatus: ServerAgentStatusMessage | null;
|
|
33
|
+
/** Whether the workstation's herdr-remote is behind; replayed the same way. */
|
|
34
|
+
updateStatus: ServerUpdateStatusMessage | null;
|
|
35
|
+
lastSeenAt: number;
|
|
36
|
+
/** Ids of the windows attached to this host. */
|
|
37
|
+
clients: Set<string>;
|
|
38
|
+
controllerId: null;
|
|
39
|
+
load: Record<string, unknown>;
|
|
40
|
+
ptys: HostPtySummary[];
|
|
41
|
+
reconnecting: boolean;
|
|
42
|
+
reconnectTimer: NodeJS.Timeout | null;
|
|
43
|
+
reconnectStartedAt?: number;
|
|
44
|
+
connectionGeneration: string;
|
|
45
|
+
handoffCapable: boolean;
|
|
46
|
+
binaryFrameV2: boolean;
|
|
47
|
+
/** v2 stream index → client id. */
|
|
48
|
+
streamIndices: Map<number, string>;
|
|
49
|
+
nextStreamIndex: number;
|
|
50
|
+
shutdownRequested: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** A connected browser window. */
|
|
53
|
+
export interface RelayClient {
|
|
54
|
+
id: string;
|
|
55
|
+
ws: RelaySocket | null;
|
|
56
|
+
hostId: string;
|
|
57
|
+
deviceId: string;
|
|
58
|
+
/** Stable per browser profile, supplied by the browser itself. */
|
|
59
|
+
browserClientId: string | null;
|
|
60
|
+
handoffCapable: boolean;
|
|
61
|
+
session: ClientSession | null;
|
|
62
|
+
role: ClientRole;
|
|
63
|
+
controllerId: null;
|
|
64
|
+
connectedAt: string;
|
|
65
|
+
connectedAtMs: number;
|
|
66
|
+
lastSeenAt: number;
|
|
67
|
+
lastPingAt: string | null;
|
|
68
|
+
bytesReceived: number;
|
|
69
|
+
bytesSent: number;
|
|
70
|
+
ip: string | null;
|
|
71
|
+
userAgent: string;
|
|
72
|
+
cols: number;
|
|
73
|
+
rows: number;
|
|
74
|
+
herdrStartAt?: number;
|
|
75
|
+
fontRefreshAt?: number;
|
|
76
|
+
/** When each outstanding font chunk request was sent. */
|
|
77
|
+
fontChunkRequests?: number[];
|
|
78
|
+
fontSubsetRequests?: number[];
|
|
79
|
+
/** Cut fonts this window may pull by hash, with their sizes. */
|
|
80
|
+
fontSubsets?: Map<string, number>;
|
|
81
|
+
}
|
|
82
|
+
/** Requests from one address within the current minute. */
|
|
83
|
+
export interface AttemptWindow {
|
|
84
|
+
startedAt: number;
|
|
85
|
+
count: number;
|
|
86
|
+
}
|
|
87
|
+
/** Frames held back by the development latency switch, in order. */
|
|
88
|
+
export interface SendQueue {
|
|
89
|
+
items: {
|
|
90
|
+
sendAt: number;
|
|
91
|
+
task: () => void;
|
|
92
|
+
}[];
|
|
93
|
+
timer: NodeJS.Timeout | null;
|
|
94
|
+
}
|
|
95
|
+
/** What the server modules share: the relay's state and two of its methods. */
|
|
96
|
+
export interface RelayContext {
|
|
97
|
+
config: RelayConfig;
|
|
98
|
+
relayMode: 'local' | 'remote';
|
|
99
|
+
hosts: Map<string, RelayHost>;
|
|
100
|
+
clients: Map<string, RelayClient>;
|
|
101
|
+
/** Stream id → client id. */
|
|
102
|
+
streams: Map<string, string>;
|
|
103
|
+
pairAttempts: Map<string, AttemptWindow>;
|
|
104
|
+
clientHandshakeAttempts: Map<string, AttemptWindow>;
|
|
105
|
+
hostHandshakeAttempts: Map<string, AttemptWindow>;
|
|
106
|
+
startedAt: number;
|
|
107
|
+
metrics: RelayMetrics;
|
|
108
|
+
auth: AuthStore;
|
|
109
|
+
adminToken: string | null;
|
|
110
|
+
trustProxy: boolean;
|
|
111
|
+
devLatencyMs: number;
|
|
112
|
+
devDelayMs: number;
|
|
113
|
+
sendQueues: WeakMap<RelaySocket, SendQueue>;
|
|
114
|
+
activeDelayTimers: Set<NodeJS.Timeout>;
|
|
115
|
+
finishHandshake(ws: RelaySocket): void;
|
|
116
|
+
address(): {
|
|
117
|
+
host: string;
|
|
118
|
+
port: number;
|
|
119
|
+
} | {
|
|
120
|
+
path: string;
|
|
121
|
+
} | null;
|
|
122
|
+
}
|
|
123
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { PACKAGE_ROOT } from '../relay-config.js';
|
|
4
|
+
/** This relay's release, as its package.json names it. */
|
|
5
|
+
export const VERSION = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
declare function ensureDir(dirPath: string): void;
|
|
2
|
+
declare function writeJsonAtomic(filePath: string, value: unknown): void;
|
|
3
|
+
/** Parsed JSON at `filePath`, or `fallback` when it is missing or unreadable. */
|
|
4
|
+
declare function readJson<T>(filePath: string, fallback: T): T;
|
|
5
|
+
declare function randomToken(bytes?: number): string;
|
|
6
|
+
export { ensureDir, writeJsonAtomic, readJson, randomToken };
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
function ensureDir(dirPath) {
|
|
5
|
+
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
|
6
|
+
try {
|
|
7
|
+
fs.chmodSync(dirPath, 0o700);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
// Some filesystems (Windows, some mounts) ignore modes; mkdir already asked for 0700.
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function writeJsonAtomic(filePath, value) {
|
|
14
|
+
ensureDir(path.dirname(filePath));
|
|
15
|
+
const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
16
|
+
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
17
|
+
try {
|
|
18
|
+
fs.chmodSync(tempPath, 0o600);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Some filesystems ignore modes; the file was written with 0600 already.
|
|
22
|
+
}
|
|
23
|
+
fs.renameSync(tempPath, filePath);
|
|
24
|
+
try {
|
|
25
|
+
fs.chmodSync(filePath, 0o600);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Some filesystems ignore modes; the file was written with 0600 already.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Parsed JSON at `filePath`, or `fallback` when it is missing or unreadable. */
|
|
32
|
+
function readJson(filePath, fallback) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
const { code, message } = error;
|
|
38
|
+
if (code !== 'ENOENT') {
|
|
39
|
+
process.stderr.write(`herdr-remote: invalid state at ${filePath}: ${message}\n`);
|
|
40
|
+
}
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function randomToken(bytes = 32) {
|
|
45
|
+
return crypto.randomBytes(bytes).toString('base64url');
|
|
46
|
+
}
|
|
47
|
+
export { ensureDir, writeJsonAtomic, readJson, randomToken };
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "herdr-remote-relay",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
4
4
|
"description": "Standalone relay server and web terminal for Herdr Remote",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
9
|
"url": "git+https://github.com/dibin666/herdr-remote.git",
|
|
@@ -25,28 +26,51 @@
|
|
|
25
26
|
"bin": {
|
|
26
27
|
"herdr-remote-relay": "./bin/herdr-remote-relay.js"
|
|
27
28
|
},
|
|
28
|
-
"main": "./
|
|
29
|
+
"main": "./dist/relay-server.js",
|
|
30
|
+
"types": "./dist/relay-server.d.ts",
|
|
29
31
|
"exports": {
|
|
30
|
-
".":
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/relay-server.d.ts",
|
|
34
|
+
"default": "./dist/relay-server.js"
|
|
35
|
+
},
|
|
36
|
+
"./protocol": {
|
|
37
|
+
"types": "./dist/protocol/index.d.ts",
|
|
38
|
+
"default": "./dist/protocol/index.js"
|
|
39
|
+
},
|
|
40
|
+
"./config": {
|
|
41
|
+
"types": "./dist/relay-config.d.ts",
|
|
42
|
+
"default": "./dist/relay-config.js"
|
|
43
|
+
},
|
|
44
|
+
"./state": {
|
|
45
|
+
"types": "./dist/state.d.ts",
|
|
46
|
+
"default": "./dist/state.js"
|
|
47
|
+
},
|
|
33
48
|
"./package.json": "./package.json"
|
|
34
49
|
},
|
|
35
50
|
"files": [
|
|
36
51
|
"bin",
|
|
37
|
-
"
|
|
52
|
+
"dist",
|
|
38
53
|
"web/dist",
|
|
39
54
|
"deploy",
|
|
40
55
|
"config.example.json",
|
|
41
56
|
"README.zh-CN.md"
|
|
42
57
|
],
|
|
43
58
|
"scripts": {
|
|
44
|
-
"build": "npm --prefix web run build",
|
|
59
|
+
"build": "npm run build:server && npm --prefix web run build",
|
|
60
|
+
"build:server": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
45
61
|
"prepack": "npm run build",
|
|
46
62
|
"start": "node bin/herdr-remote-relay.js",
|
|
47
|
-
"
|
|
63
|
+
"pretest": "npm run build:server",
|
|
64
|
+
"test": "vitest run",
|
|
65
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
48
66
|
},
|
|
49
67
|
"dependencies": {
|
|
50
68
|
"ws": "^8.18.0"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@types/node": "^22.20.4",
|
|
72
|
+
"@types/ws": "^8.18.1",
|
|
73
|
+
"typescript": "~5.7.3",
|
|
74
|
+
"vitest": "^3.2.7"
|
|
51
75
|
}
|
|
52
76
|
}
|