@w3cj/fwd 0.0.0-stage → 0.1.0

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/help.js ADDED
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { commands, helpOption, hostOption, optionsFor } from './commands/index.js';
3
+
4
+ /** @typedef {import('./commands/common.js').Command} Command */
5
+ /** @typedef {import('./commands/common.js').Option} Option */
6
+
7
+ export const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
8
+
9
+ /** @param {[string, string][]} rows */
10
+ function table(rows) {
11
+ const width = Math.max(...rows.map(([left]) => left.length));
12
+ return rows.map(([left, right]) => ` ${left.padEnd(width)} ${right}`).join('\n');
13
+ }
14
+
15
+ /**
16
+ * @param {Record<string, Option>} options
17
+ * @returns {[string, string][]}
18
+ */
19
+ function optionRows(options) {
20
+ return Object.entries(options).map(([name, opt]) => [
21
+ `${opt.short ? `-${opt.short}, ` : ''}--${name}${opt.type === 'string' ? ` <${opt.value ?? name}>` : ''}`,
22
+ opt.help,
23
+ ]);
24
+ }
25
+
26
+ export function mainHelp() {
27
+ return `fwd ${version}: manage SSH port forwards over a single ControlMaster connection
28
+
29
+ Usage: fwd <command> [options]
30
+
31
+ Commands:
32
+ ${table(commands.filter((c) => !c.hidden).map((c) => [c.name, c.summary]))}
33
+
34
+ Options:
35
+ ${table([...optionRows({ ...hostOption, ...helpOption }), ['-v, --version', 'Show the version']])}
36
+
37
+ Run \`fwd help <command>\` for details. Start with \`fwd config set host <host>\`.`;
38
+ }
39
+
40
+ /** @param {Command} cmd */
41
+ export function commandHelp(cmd) {
42
+ const usage = cmd.usage.map((line, i) => `${i === 0 ? 'Usage: ' : ' '}${line}`).join('\n');
43
+ const description = cmd.description ? `\n\n${cmd.description}` : '';
44
+ return `${usage}\n\n${cmd.summary}.${description}\n\nOptions:\n${table(optionRows(optionsFor(cmd)))}`;
45
+ }
package/src/lock.js ADDED
@@ -0,0 +1,76 @@
1
+ import { link, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { FwdError } from './errors.js';
4
+ import { configDir } from './paths.js';
5
+
6
+ const TIMEOUT_MS = 60_000;
7
+
8
+ function lockPath() {
9
+ return join(configDir(), 'lock');
10
+ }
11
+
12
+ /** @param {number} pid */
13
+ function isRunning(pid) {
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ } catch (err) {
18
+ return /** @type {NodeJS.ErrnoException} */ (err).code === 'EPERM';
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Create the lock file atomically with our pid already in it: write a temp
24
+ * file, then hard-link it into place (link fails if the lock exists).
25
+ * @returns {Promise<boolean>}
26
+ */
27
+ async function tryAcquire() {
28
+ const file = lockPath();
29
+ const tmp = `${file}.${process.pid}`;
30
+ await writeFile(tmp, String(process.pid));
31
+ try {
32
+ await link(tmp, file);
33
+ return true;
34
+ } catch (err) {
35
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'EEXIST') return false;
36
+ throw err;
37
+ } finally {
38
+ await rm(tmp, { force: true });
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Run fn while holding an exclusive lock, so concurrent fwd commands can't
44
+ * lose each other's state updates or start two masters for one host. A lock
45
+ * left by a process that no longer exists is taken over.
46
+ * @template T
47
+ * @param {() => Promise<T>} fn
48
+ * @returns {Promise<T>}
49
+ */
50
+ export async function withLock(fn) {
51
+ await mkdir(configDir(), { recursive: true });
52
+ const started = Date.now();
53
+ let waiting = false;
54
+
55
+ while (!(await tryAcquire())) {
56
+ const holder = Number(await readFile(lockPath(), 'utf8').catch(() => ''));
57
+ if (holder && !isRunning(holder)) {
58
+ await rm(lockPath(), { force: true });
59
+ continue;
60
+ }
61
+ if (Date.now() - started > TIMEOUT_MS) {
62
+ throw new FwdError(`Another fwd command (pid ${holder}) is still running. If it's stuck, stop it and try again.`);
63
+ }
64
+ if (!waiting && Date.now() - started > 1000) {
65
+ console.error(`fwd: waiting for another fwd command (pid ${holder}) to finish...`);
66
+ waiting = true;
67
+ }
68
+ await new Promise((resolve) => setTimeout(resolve, 50));
69
+ }
70
+
71
+ try {
72
+ return await fn();
73
+ } finally {
74
+ await rm(lockPath(), { force: true });
75
+ }
76
+ }
package/src/paths.js ADDED
@@ -0,0 +1,10 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ // State, sockets and logs live in $XDG_CONFIG_HOME/fwd (~/.config/fwd by
5
+ // default). FWD_CONFIG_DIR is an override so tests don't touch the real one.
6
+ export function configDir() {
7
+ if (process.env.FWD_CONFIG_DIR) return process.env.FWD_CONFIG_DIR;
8
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
9
+ return join(base, 'fwd');
10
+ }
package/src/ports.js ADDED
@@ -0,0 +1,45 @@
1
+ import net from 'node:net';
2
+
3
+ /**
4
+ * True if something accepts TCP connections on 127.0.0.1:<port>.
5
+ * @param {number} port
6
+ * @param {number} [timeoutMs]
7
+ * @returns {Promise<boolean>}
8
+ */
9
+ export function isPortListening(port, timeoutMs = 500) {
10
+ return new Promise((resolve) => {
11
+ const socket = net.connect({ port, host: '127.0.0.1' });
12
+ /** @param {boolean} result */
13
+ const done = (result) => {
14
+ socket.destroy();
15
+ resolve(result);
16
+ };
17
+ socket.setTimeout(timeoutMs, () => done(false));
18
+ socket.once('connect', () => done(true));
19
+ socket.once('error', () => done(false));
20
+ });
21
+ }
22
+
23
+ /**
24
+ * @param {number} port
25
+ * @returns {Promise<boolean>}
26
+ */
27
+ function canBind(port) {
28
+ return new Promise((resolve) => {
29
+ const server = net.createServer();
30
+ server.once('error', () => resolve(false));
31
+ server.listen({ port, host: '127.0.0.1', exclusive: true }, () => {
32
+ server.close(() => resolve(true));
33
+ });
34
+ });
35
+ }
36
+
37
+ /**
38
+ * True if ssh should be able to listen on localhost:<port>. Both checks are
39
+ * needed: on macOS a wildcard listener doesn't always block a 127.0.0.1 bind.
40
+ * @param {number} port
41
+ * @returns {Promise<boolean>}
42
+ */
43
+ export async function isPortFree(port) {
44
+ return !(await isPortListening(port)) && (await canBind(port));
45
+ }
package/src/spec.js ADDED
@@ -0,0 +1,145 @@
1
+ import { FwdError } from './errors.js';
2
+
3
+ /**
4
+ * 'local' listens on this machine and connects from the host (ssh -L).
5
+ * 'remote' listens on the host and connects from this machine (ssh -R).
6
+ * @typedef {'local' | 'remote'} Direction
7
+ */
8
+
9
+ /**
10
+ * @typedef {object} Forward
11
+ * @property {Direction} direction
12
+ * @property {number} local Port on this machine.
13
+ * @property {number} remote Port on the host.
14
+ * @property {string} to Address dialled by the connecting side: from the host
15
+ * for local forwards, from this machine for remote ones.
16
+ * @property {string} [addedAt]
17
+ */
18
+
19
+ const DEFAULT_TO = '127.0.0.1';
20
+
21
+ /**
22
+ * @param {string} value
23
+ * @param {string} label
24
+ * @returns {number}
25
+ */
26
+ export function parsePort(value, label) {
27
+ if (!/^\d+$/.test(value)) {
28
+ throw new FwdError(`Invalid ${label} "${value}": expected a number.`);
29
+ }
30
+ const port = Number(value);
31
+ if (port < 1 || port > 65535) {
32
+ throw new FwdError(`Invalid ${label} ${port}: must be between 1 and 65535.`);
33
+ }
34
+ return port;
35
+ }
36
+
37
+ /**
38
+ * Parse "5173" or "8080:3000" (<local>:<remote> in both directions).
39
+ * @param {string} input
40
+ * @param {{ to?: string, reverse?: boolean }} [options]
41
+ * @returns {Forward}
42
+ */
43
+ export function parseSpec(input, { to, reverse = false } = {}) {
44
+ const parts = input.split(':');
45
+ if (parts.length > 2) {
46
+ throw new FwdError(
47
+ `Invalid forward "${input}". Use <local> or <local>:<remote>, and --to <addr> for a different address.`,
48
+ );
49
+ }
50
+ const local = parsePort(parts[0], 'local port');
51
+ const remote = parts.length === 2 ? parsePort(parts[1], 'remote port') : local;
52
+ /** @type {Forward} */
53
+ const fwd = { direction: reverse ? 'remote' : 'local', local, remote, to: parseAddress(to) };
54
+
55
+ const listen = listenPort(fwd);
56
+ if (listen < 1024) {
57
+ const suggestion = reverse ? `${local}:${remote + 8000} --reverse` : `${local + 8000}:${remote}`;
58
+ throw new FwdError(
59
+ `${reverse ? 'Remote' : 'Local'} port ${listen} is privileged (below 1024). Listen on a higher port, e.g. \`fwd add ${suggestion}\`.`,
60
+ );
61
+ }
62
+ return fwd;
63
+ }
64
+
65
+ /**
66
+ * @param {string | undefined} to
67
+ * @returns {string}
68
+ */
69
+ function parseAddress(to) {
70
+ if (to === undefined) return DEFAULT_TO;
71
+ // Accept "[::1]" as well as "::1"; brackets are added back when talking to ssh.
72
+ const address = to.replace(/^\[(.*)\]$/, '$1');
73
+ if (!address || /[\s[\]/]/.test(address)) {
74
+ throw new FwdError(`Invalid --to address "${to}".`);
75
+ }
76
+ return address;
77
+ }
78
+
79
+ /**
80
+ * The port that is listened on: on this machine for local forwards, on the
81
+ * host for remote ones.
82
+ * @param {Forward} fwd
83
+ */
84
+ export function listenPort(fwd) {
85
+ return fwd.direction === 'local' ? fwd.local : fwd.remote;
86
+ }
87
+
88
+ /**
89
+ * Identifies a forward within a host: two forwards with the same key would
90
+ * listen on the same port.
91
+ * @param {Direction} direction
92
+ * @param {number} port The listen port.
93
+ */
94
+ export function forwardKey(direction, port) {
95
+ return `${direction}:${port}`;
96
+ }
97
+
98
+ /** @param {Forward} fwd */
99
+ export function keyOf(fwd) {
100
+ return forwardKey(fwd.direction, listenPort(fwd));
101
+ }
102
+
103
+ /** @param {string} address */
104
+ function bracket(address) {
105
+ return address.includes(':') ? `[${address}]` : address;
106
+ }
107
+
108
+ /**
109
+ * The -L/-R arguments for ssh. `-O cancel` must receive exactly the same
110
+ * spec that `-O forward` did.
111
+ * @param {Forward} fwd
112
+ * @returns {[string, string]}
113
+ */
114
+ export function toSshArgs(fwd) {
115
+ return fwd.direction === 'local'
116
+ ? ['-L', `${fwd.local}:${bracket(fwd.to)}:${fwd.remote}`]
117
+ : ['-R', `${fwd.remote}:${bracket(fwd.to)}:${fwd.local}`];
118
+ }
119
+
120
+ /**
121
+ * @param {Forward} a
122
+ * @param {Forward} b
123
+ */
124
+ export function sameTarget(a, b) {
125
+ return a.direction === b.direction && a.local === b.local && a.remote === b.remote && a.to === b.to;
126
+ }
127
+
128
+ /** @param {string} address */
129
+ function isLoopback(address) {
130
+ return address === DEFAULT_TO || address === 'localhost';
131
+ }
132
+
133
+ /**
134
+ * Human-readable forward, e.g. "localhost:5173 -> vm:5173",
135
+ * "localhost:5432 -> db:5432 (via vm)" or "vm:3000 -> localhost:3000".
136
+ * @param {Forward} fwd
137
+ * @param {string} host
138
+ */
139
+ export function describe(fwd, host) {
140
+ if (fwd.direction === 'remote') {
141
+ return `${host}:${fwd.remote} -> ${isLoopback(fwd.to) ? 'localhost' : bracket(fwd.to)}:${fwd.local}`;
142
+ }
143
+ const target = isLoopback(fwd.to) ? `${host}:${fwd.remote}` : `${bracket(fwd.to)}:${fwd.remote} (via ${host})`;
144
+ return `localhost:${fwd.local} -> ${target}`;
145
+ }
package/src/ssh.js ADDED
@@ -0,0 +1,180 @@
1
+ // The only module that knows ssh's command-line syntax.
2
+ import { execFile, spawn } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { FwdError } from './errors.js';
7
+ import { configDir } from './paths.js';
8
+ import { describe, toSshArgs } from './spec.js';
9
+
10
+ /** @typedef {import('./spec.js').Forward} Forward */
11
+
12
+ // FWD_SSH lets tests (or users) point at a wrapper, e.g. one that adds -F.
13
+ function sshBin() {
14
+ return process.env.FWD_SSH || 'ssh';
15
+ }
16
+
17
+ function sockDir() {
18
+ return join(configDir(), 'sock');
19
+ }
20
+
21
+ /**
22
+ * macOS caps Unix socket paths at 104 bytes, so use a short hash rather than
23
+ * the raw host string.
24
+ * @param {string} host
25
+ */
26
+ export function socketPath(host) {
27
+ const hash = createHash('sha256').update(host).digest('hex').slice(0, 16);
28
+ return join(sockDir(), hash);
29
+ }
30
+
31
+ /** @param {string} host */
32
+ export function logPath(host) {
33
+ return `${socketPath(host)}.log`;
34
+ }
35
+
36
+ /**
37
+ * @param {string[]} args
38
+ * @returns {Promise<{ code: number, stderr: string }>}
39
+ */
40
+ function run(args) {
41
+ return new Promise((resolve, reject) => {
42
+ execFile(sshBin(), args, { timeout: 15_000 }, (err, _stdout, stderr) => {
43
+ if (err && /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') {
44
+ reject(notFound());
45
+ return;
46
+ }
47
+ const code = err ? (typeof err.code === 'number' ? err.code : 1) : 0;
48
+ resolve({ code, stderr: stderr.replace(/\r/g, '').trim() });
49
+ });
50
+ });
51
+ }
52
+
53
+ function notFound() {
54
+ return new FwdError(`Could not run "${sshBin()}". Is OpenSSH installed and on your PATH?`);
55
+ }
56
+
57
+ /**
58
+ * Control commands only talk to the master's socket, so they skip the ssh
59
+ * config: otherwise `-O forward` would also send every LocalForward in it.
60
+ * @param {string} host
61
+ * @param {string} op
62
+ * @param {string[]} [extra]
63
+ */
64
+ function control(host, op, extra = []) {
65
+ return run(['-F', 'none', '-S', socketPath(host), '-O', op, ...extra, '--', host]);
66
+ }
67
+
68
+ /**
69
+ * Turn ssh's stderr from a failed control command into a readable error.
70
+ * @param {string} host
71
+ * @param {string} action
72
+ * @param {string} stderr
73
+ */
74
+ function controlError(host, action, stderr) {
75
+ if (/control socket connect|no such file|connection refused/i.test(stderr)) {
76
+ return new FwdError(`Could not ${action}: the SSH master for ${host} is not running.`);
77
+ }
78
+ const detail = stderr.replace(/^mux_?client\w*: /gm, '');
79
+ return new FwdError(`Could not ${action}: ${detail || 'ssh exited with an error'}`);
80
+ }
81
+
82
+ /**
83
+ * @param {string} host
84
+ * @returns {Promise<{ alive: true, pid: number | null } | { alive: false }>}
85
+ */
86
+ export async function checkMaster(host) {
87
+ const { code, stderr } = await control(host, 'check');
88
+ if (code !== 0) return { alive: false };
89
+ const match = /pid=(\d+)/.exec(stderr);
90
+ return { alive: true, pid: match ? Number(match[1]) : null };
91
+ }
92
+
93
+ /**
94
+ * Start a ControlMaster in the background. stdin is inherited and ssh uses
95
+ * /dev/tty for passphrase and host-key prompts. stdout/stderr are not
96
+ * inherited because the backgrounded master would hold them open forever
97
+ * (breaking `fwd up | cat`); ssh logs to a file instead and we show that on
98
+ * failure.
99
+ *
100
+ * ClearAllForwardings drops LocalForward/RemoteForward lines from the user's
101
+ * ssh config: fwd's state is the only source of forwards, and with
102
+ * ExitOnForwardFailure a busy config port would stop the master starting.
103
+ * @param {string} host
104
+ */
105
+ export async function startMaster(host) {
106
+ await mkdir(sockDir(), { recursive: true, mode: 0o700 });
107
+ const log = logPath(host);
108
+ await writeFile(log, '');
109
+ const args = [
110
+ '-M',
111
+ '-S', socketPath(host),
112
+ '-fN',
113
+ '-E', log,
114
+ '-o', 'ControlPersist=no',
115
+ '-o', 'ExitOnForwardFailure=yes',
116
+ '-o', 'ClearAllForwardings=yes',
117
+ '-o', 'ConnectTimeout=10',
118
+ '-o', 'ServerAliveInterval=15',
119
+ '-o', 'ServerAliveCountMax=3',
120
+ '--', host,
121
+ ];
122
+ /** @type {number} */
123
+ const code = await new Promise((resolve, reject) => {
124
+ const child = spawn(sshBin(), args, { stdio: ['inherit', 'ignore', 'ignore'] });
125
+ child.once('error', (err) => {
126
+ reject(/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT' ? notFound() : err);
127
+ });
128
+ child.once('exit', (exitCode) => resolve(exitCode ?? 1));
129
+ });
130
+ if (code !== 0) {
131
+ const output = (await readFile(log, 'utf8').catch(() => '')).trim();
132
+ const detail = output ? `\n${output.replace(/^/gm, ' ')}` : '';
133
+ throw new FwdError(`Could not connect to ${host} (ssh exited with code ${code}).${detail}`);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * @param {string} host
139
+ */
140
+ export async function stopMaster(host) {
141
+ const { code, stderr } = await control(host, 'exit');
142
+ if (code !== 0) throw controlError(host, 'stop the SSH master', stderr);
143
+ }
144
+
145
+ /**
146
+ * Remove a socket file left behind by a master that died uncleanly.
147
+ * @param {string} host
148
+ */
149
+ export async function removeSocket(host) {
150
+ await rm(socketPath(host), { force: true });
151
+ }
152
+
153
+ /**
154
+ * ssh can exit 0 even when the master rejected the request (e.g. cancelling a
155
+ * forward it doesn't have), so check stderr as well.
156
+ * @param {string} host
157
+ * @param {'forward' | 'cancel'} op
158
+ * @param {Forward} fwd
159
+ * @param {string} action
160
+ */
161
+ async function forwardRequest(host, op, fwd, action) {
162
+ const { code, stderr } = await control(host, op, toSshArgs(fwd));
163
+ if (code !== 0 || /request failed/i.test(stderr)) throw controlError(host, action, stderr);
164
+ }
165
+
166
+ /**
167
+ * @param {string} host
168
+ * @param {Forward} fwd
169
+ */
170
+ export function addForward(host, fwd) {
171
+ return forwardRequest(host, 'forward', fwd, `forward ${describe(fwd, host)}`);
172
+ }
173
+
174
+ /**
175
+ * @param {string} host
176
+ * @param {Forward} fwd
177
+ */
178
+ export function cancelForward(host, fwd) {
179
+ return forwardRequest(host, 'cancel', fwd, `cancel ${describe(fwd, host)}`);
180
+ }
package/src/state.js ADDED
@@ -0,0 +1,130 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { FwdError } from './errors.js';
4
+ import { configDir } from './paths.js';
5
+ import { keyOf, listenPort } from './spec.js';
6
+
7
+ /** @typedef {import('./spec.js').Forward} Forward */
8
+
9
+ /**
10
+ * @typedef {object} State
11
+ * @property {string | null} defaultHost
12
+ * @property {Record<string, { forwards: Forward[] }>} hosts
13
+ */
14
+
15
+ // Bump when the file format changes, and migrate older versions in loadState.
16
+ const STATE_VERSION = 2;
17
+
18
+ /**
19
+ * Version 1 (and unversioned 0.1.0 files) only had local forwards, with the
20
+ * dialled address in `remoteHost`.
21
+ * @param {{ local: number, remote: number, remoteHost: string, addedAt?: string }} fwd
22
+ * @returns {Forward}
23
+ */
24
+ function migrateV1Forward({ local, remote, remoteHost, addedAt }) {
25
+ return { direction: 'local', local, remote, to: remoteHost, ...(addedAt && { addedAt }) };
26
+ }
27
+
28
+ export function statePath() {
29
+ return join(configDir(), 'state.json');
30
+ }
31
+
32
+ /** @returns {Promise<State>} */
33
+ export async function loadState() {
34
+ let raw;
35
+ try {
36
+ raw = await readFile(statePath(), 'utf8');
37
+ } catch (err) {
38
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') {
39
+ return { defaultHost: null, hosts: {} };
40
+ }
41
+ throw err;
42
+ }
43
+ let data;
44
+ try {
45
+ data = JSON.parse(raw);
46
+ } catch (err) {
47
+ throw new FwdError(`State file ${statePath()} is not valid JSON: ${/** @type {Error} */ (err).message}`);
48
+ }
49
+ // Files written by 0.1.0 have no version; they're the same format as 1.
50
+ const version = data.version ?? 1;
51
+ if (version > STATE_VERSION) {
52
+ throw new FwdError(`State file ${statePath()} was written by a newer version of fwd. Upgrade fwd to use it.`);
53
+ }
54
+ const hosts = data.hosts ?? {};
55
+ if (version < 2) {
56
+ for (const entry of Object.values(hosts)) entry.forwards = entry.forwards.map(migrateV1Forward);
57
+ }
58
+ return { defaultHost: data.defaultHost ?? null, hosts };
59
+ }
60
+
61
+ /**
62
+ * Write to a temp file and rename it into place so a crash can't leave a
63
+ * half-written state file.
64
+ * @param {State} state
65
+ */
66
+ export async function saveState(state) {
67
+ const file = statePath();
68
+ await mkdir(dirname(file), { recursive: true });
69
+ const tmp = `${file}.${process.pid}.tmp`;
70
+ try {
71
+ await writeFile(tmp, `${JSON.stringify({ version: STATE_VERSION, ...state }, null, 2)}\n`);
72
+ await rename(tmp, file);
73
+ } catch (err) {
74
+ await rm(tmp, { force: true });
75
+ throw err;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * @param {State} state
81
+ * @param {string} host
82
+ * @returns {Forward[]}
83
+ */
84
+ export function getForwards(state, host) {
85
+ return state.hosts[host]?.forwards ?? [];
86
+ }
87
+
88
+ /**
89
+ * @param {State} state
90
+ * @param {string} host
91
+ * @param {string} key From forwardKey/keyOf.
92
+ * @returns {Forward | undefined}
93
+ */
94
+ export function findForward(state, host, key) {
95
+ return getForwards(state, host).find((f) => keyOf(f) === key);
96
+ }
97
+
98
+ /**
99
+ * Store forwards sorted by direction then listen port, dropping the host once
100
+ * it has none.
101
+ * @param {State} state
102
+ * @param {string} host
103
+ * @param {Forward[]} forwards
104
+ */
105
+ function setForwards(state, host, forwards) {
106
+ if (forwards.length) {
107
+ state.hosts[host] = { ...state.hosts[host], forwards: forwards.sort((a, b) => a.direction.localeCompare(b.direction) || listenPort(a) - listenPort(b)) };
108
+ } else {
109
+ delete state.hosts[host];
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Add or replace the forward that listens on the same port.
115
+ * @param {State} state
116
+ * @param {string} host
117
+ * @param {Forward} fwd
118
+ */
119
+ export function putForward(state, host, fwd) {
120
+ setForwards(state, host, [...getForwards(state, host).filter((f) => keyOf(f) !== keyOf(fwd)), fwd]);
121
+ }
122
+
123
+ /**
124
+ * @param {State} state
125
+ * @param {string} host
126
+ * @param {string} key From forwardKey/keyOf.
127
+ */
128
+ export function removeForward(state, host, key) {
129
+ setForwards(state, host, getForwards(state, host).filter((f) => keyOf(f) !== key));
130
+ }